DEV Community

Emily Thomas
Emily Thomas

Posted on

Your System Design Is Probably Wrong (Here's Why)

Every engineer eventually hits the same wall: the code works, but the system doesn't. Requests time out under load. One team's deploy breaks another team's service. Nobody can explain why the database is the bottleneck at 2 AM. That's not a coding problem — that's a system design problem.

Let's walk through the architecture patterns that actually matter in 2026, with real examples instead of whiteboard theory.

Monolith vs Microservices: The Question Everyone Gets Wrong

The internet loves to frame this as a binary choice. It isn't. The real question is: how much independent scaling and deployment do you actually need right now?

A monolith isn't a legacy mistake — it's often the correct starting point.

// A well-structured monolith: one deployable, clear internal boundaries
// src/modules/orders/orderService.js
export async function createOrder(userId, items) {
  const user = await userRepository.findById(userId);
  const total = calculateTotal(items);
  const order = await orderRepository.create({ userId, items, total });
  await inventoryService.reserveStock(items);
  await notificationService.sendOrderConfirmation(user.email, order);
  return order;
}
Enter fullscreen mode Exit fullscreen mode

Everything above lives in one codebase, one deploy pipeline, one database. That's not a weakness — it's simplicity you'll miss once you split it up.

Microservices earn their complexity when teams need to ship independently, or when one part of the system has wildly different scaling needs than the rest.

// order-service (independent deployable)
app.post("/orders", async (req, res) => {
  const order = await createOrder(req.body);

  // Fire-and-forget event instead of a direct call
  await eventBus.publish("order.created", { orderId: order.id, userId: order.userId });

  res.status(201).json(order);
});
Enter fullscreen mode Exit fullscreen mode
// inventory-service (separate deployable, subscribes to the event)
eventBus.subscribe("order.created", async (event) => {
  await reserveStock(event.orderId);
});
Enter fullscreen mode Exit fullscreen mode

Notice the shift: instead of orderService directly calling inventoryService, they communicate through events. That decoupling is the entire point of microservices — and also the entire source of their complexity (retries, eventual consistency, distributed tracing).

Event-Driven Architecture: Decoupling as a Feature

Once you have more than a couple of services, direct HTTP calls between them create a web of tight coupling. Event-driven design flips that:

// A minimal event bus abstraction (backed by Kafka, SQS, or Redis Streams)
class EventBus {
  async publish(topic, payload) {
    await this.broker.send(topic, JSON.stringify(payload));
  }

  subscribe(topic, handler) {
    this.broker.on(topic, async (message) => {
      try {
        await handler(JSON.parse(message));
      } catch (err) {
        await this.deadLetterQueue.push(topic, message, err);
      }
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

The dead-letter queue matters more than people think — in a distributed system, failures are guaranteed, not exceptional. Design for them from day one, not after the first 3 AM page.

The Database Layer: Where Most Systems Actually Break

Most "system design" failures aren't about services — they're about data. A few patterns worth internalizing:

Read replicas for read-heavy workloads:

const writeDb = new Pool({ host: "primary.db.internal" });
const readDb = new Pool({ host: "replica.db.internal" });

async function getOrder(orderId) {
  return readDb.query("SELECT * FROM orders WHERE id = $1", [orderId]);
}

async function createOrder(data) {
  return writeDb.query("INSERT INTO orders (...) VALUES (...)", [data]);
}
Enter fullscreen mode Exit fullscreen mode

Caching in front of the database:

async function getProduct(productId) {
  const cached = await redis.get(`product:${productId}`);
  if (cached) return JSON.parse(cached);

  const product = await db.query("SELECT * FROM products WHERE id = $1", [productId]);
  await redis.set(`product:${productId}`, JSON.stringify(product), "EX", 300);
  return product;
}
Enter fullscreen mode Exit fullscreen mode

Cache invalidation is the classic hard problem here — a 5-minute TTL is a blunt but honest tool until you need something smarter like write-through invalidation.

Choosing Patterns Without Overengineering

A rough guide that's saved me from a lot of premature complexity:

Signal Likely Fit
Single small team, unclear scaling needs Monolith
Multiple teams, independent release cycles Microservices
High write volume, async processing tolerable Event-driven
Read-heavy, latency-sensitive Caching + read replicas
Complex workflows spanning services Orchestration (Step Functions, Temporal)

If you're documenting these decisions across a growing team, a software hub is worth setting up early — architecture decision records get lost in Slack threads fast, and having one place where the "why" behind each pattern lives saves a lot of re-litigating old debates six months later.

A Practical Middle Ground: Modular Monolith

If microservices feel premature but a tangled monolith scares you, there's a well-tested middle ground: structure the monolith with hard internal boundaries, as if it were already split.

src/
  modules/
    orders/       # owns its own DB tables, no cross-module SQL
    inventory/
    notifications/
  shared/
    eventBus.js   # internal pub/sub, same process
Enter fullscreen mode Exit fullscreen mode
// modules/orders/index.js — the only public interface to this module
export { createOrder, getOrder, cancelOrder } from "./orderService.js";
// Nothing else is importable from outside this folder
Enter fullscreen mode Exit fullscreen mode

This gets you most of the discipline of microservices — clear ownership, enforced boundaries — without the operational tax of distributed systems. When (and if) you actually need to split a module into its own service later, the boundary is already drawn.

Wrapping Up

Good system design isn't about picking the trendiest pattern — it's about matching the architecture to the actual constraints: team size, scaling needs, consistency requirements. Start simpler than you think you need to. The cost of splitting a monolith later is real but manageable. The cost of prematurely distributing a system you didn't need to distribute is much harder to undo.


Want a deeper breakdown of trade-offs between these patterns? Check out our *Serverless Architecture Guide** for how these ideas play out once you add serverless and edge compute into the mix.*

Top comments (0)