Have you ever sat in a planning meeting where someone said "let's go event-driven", and the entire room nodded, and nobody asked what problem event-driven was supposed to solve?

You've probably been in that room. Or you've watched a startup of twelve engineers split a healthy monolith into nine services because the CTO read a Netflix postmortem on the flight back from a conference. Six months later, deploys are slower, latency is worse, the on-call rotation is on fire, and nobody can remember what the original argument for splitting was.

Architecture is the only field of engineering where we routinely choose the answer before we read the question.

That's the whole problem. Patterns aren't ideologies. They aren't team identities. They aren't badges you wear because you're a "modern" shop. Every pattern in the popular menu (MVC, modular monolith, microservices, event-driven, domain-driven design) was invented for a specific kind of pressure on a specific kind of system. The question isn't "which one is best?". It's "which kind of pressure are we under right now?".

Let's walk through them honestly, with the trade-offs out in the open.

Start From The Problem, Not The Pattern

Architecture decisions go wrong in a predictable shape. The team picks a pattern from a talk or a tweet, then spends two years trying to make their actual problem fit that pattern. The opposite move: describe the problem first, then reach for the pattern. It sounds obvious, but almost nobody does it.

Try this exercise the next time someone proposes a rearchitecture. Ask three questions, in order:

The first one is what's the painful behavior? Not the abstract "we need scalability". The concrete one. "Our deploys take 40 minutes and one team blocks four others." "Two services share a database and every schema change is a cross-team negotiation." "A spike in webhooks knocks over the order service and customers see 500s." If you can't name a painful behavior, the pattern isn't the answer; nothing is.

The second one is what's the cost of the change? Every architectural move costs you something: usually a year of slowdown, a wave of bugs, a couple of senior engineers hired specifically to manage the new shape, and a learning curve for everyone else. Are you buying enough relief to cover that bill?

The third one is who's going to live with it? Architecture is a contract with your future team. If the people who will run the system in two years aren't on board, the pattern won't survive contact with their fatigue.

Only after those three questions does it make sense to look at the menu.

Five architecture patterns mapped to the problems they solve and their typical costs: MVC, Modular Monolith, Microservices, Event-Driven, and Domain-Driven Design.

MVC: When The Job Is Mostly Showing Things

MVC isn't a system architecture. It's a request-handling pattern. It's how you organize one slice of code: a route comes in, a controller handles it, the model touches data, the view renders something out. It earns its keep when the job of your application is fundamentally displaying and manipulating records.

That covers an enormous slice of real software. CRUD admin panels, content sites, dashboards, internal tools, most B2B SaaS for the first two years. If your domain logic is "validate this form, save this row, render this list", MVC is the right amount of structure. Anything more is overhead you'll regret.

The pattern starts to ache when the verb in your application stops being show and starts being do. Charge a card. Run a workflow. Apply a discount that depends on six variables. Coordinate four downstream services. The instant your business logic has its own shape, its own rules, its own lifecycle, the controller-model split runs out of vocabulary, and you'll feel it. That's the moment to add a service layer or domain layer inside MVC, not the moment to throw MVC out.

TypeScript app/orders/orderController.ts
// MVC at its happiest: thin controller, the work belongs elsewhere.
import { CreateOrder } from "@/services/orders/createOrder";

export async function create(req: Request, res: Response) {
  const cmd = CreateOrderRequest.parse(req.body);
  const order = await CreateOrder.run(cmd, req.user);
  return res.status(201).json(order);
}

The controller is the postman. It validates the envelope, hands it off, and returns the receipt. If your controllers look like 200-line workflow scripts, MVC isn't the problem. Your use of it is.

Modular Monolith: The Pattern Nobody Brags About

If MVC is the framework default and microservices is the conference darling, the modular monolith is the quiet workhorse that almost always wins for teams between five and forty engineers, and almost nobody talks about it because there's nothing flashy to demo.

A modular monolith is one deployable unit, one process, one database connection pool. But inside,, the code is split into clearly bounded modules with explicit interfaces between them. billing doesn't reach into inventory.models. It calls inventory.publicApi. The compiler, or your linter, or your code review process, enforces it.

You get most of the benefits people want from microservices, with almost none of the pain:

  • One deploy, one version of the truth, one place to roll back when something breaks at 2am.
  • One database transaction when you need it. One log stream. One observability stack.
  • Module boundaries that you can refactor in an afternoon, because they're still in the same repo and the same compiler can prove that you didn't break anything.
Python services/billing/public_api.py
# Anything outside `billing/` imports from here, and only here.
# Internal modules import freely from each other.

from .application.charge_card import charge_card
from .application.refund_payment import refund_payment
from .application.list_invoices import list_invoices

__all__ = ["charge_card", "refund_payment", "list_invoices"]

A small import-linter rule, "no module may import from another module's internals, only from its public_api", is the entire enforcement story. That one rule does most of what a service mesh and a contract test suite do, and it costs nothing at runtime.

The trade-off is real, and worth naming honestly. A modular monolith only works if the team agrees to respect the boundaries. The compiler can stop import violations, but it can't stop a senior dev from arguing "I'll just reach into the billing tables this once, it's faster." Once that rot starts, the modules collapse and you have a regular, scary monolith. The discipline is the architecture.

Microservices: The Tax You Pay To Buy Team Independence

The thing nobody admits about microservices is that they aren't an architecture decision. They're an organization decision dressed up as one.

You don't go microservices because your CPUs are slow. CPUs are fine. Networks are slower. You go microservices because you have too many engineers stepping on each other in one repo, and you need to draw fences so each team can move at its own pace. That's the actual problem. Latency, complexity, observability tooling, deploy pipelines, schema coordination meetings, on-call rotations for each service: those are the tax. The prize is independence.

If you don't need that prize, you shouldn't be paying that tax.

The honest signs you've earned the tax:

  • More than ~50 engineers in one repo, with measurable interference (slow CI, scary merges, deploys queued behind other teams' work).
  • Distinct release cadences that can't be reconciled: billing must ship daily, the data pipeline ships weekly, the mobile API can't break clients for 30 days.
  • A real organizational structure where teams own a domain end-to-end and need the autonomy to evolve their database schema, language choice, and deploy timing.

The dishonest signs people usually use:

  • "It'll be more scalable." It probably won't. Most monoliths can scale horizontally just fine. The bottleneck is usually the database, and you'll bring the database with you.
  • "It'll be cleaner." Clean architecture is achievable inside one process. Microservices don't make code cleaner. They make boundaries more expensive to violate, which is a different thing.
  • "All the big companies do it." All the big companies also run thirty-year-old C++ trading systems and ten-thousand-line stored procedures. You're not picking from a curated menu of best practices.
Go orders/service.go
// A microservice boundary worth its weight.
// Notice what's NOT here: shared models with the inventory service,
// shared database access, shared deploy. Independence is the whole point.
type OrderService struct {
    repo      OrderRepository
    inventory InventoryClient // a client over the network, not an in-process call
    payments  PaymentsClient
    events    EventBus
}

func (s *OrderService) Place(ctx context.Context, cmd PlaceOrder) (*Order, error) {
    if err := s.inventory.Reserve(ctx, cmd.Items); err != nil {
        return nil, fmt.Errorf("reserve: %w", err)
    }
    order := NewOrder(cmd)
    if err := s.repo.Save(ctx, order); err != nil {
        return nil, err
    }
    s.events.Publish(ctx, OrderPlaced{ID: order.ID})
    return order, nil
}

Look at that code closely. Every external call is a network hop you have to think about: retries, timeouts, partial failures, idempotency keys. Every event you publish is a contract you owe to consumers you don't control. Every schema change is a coordination problem. None of this is wrong; it's just a lot. Make sure you're getting an organizational prize big enough to cover it.

Event-Driven Architecture: When Time Is Part Of The Domain

Event-driven architecture is the most over-prescribed pattern of the last five years, and it's the one that hurts the most when you choose it for the wrong reasons. EDA isn't decoration. It's a fundamentally different way of thinking about time in your system.

In a synchronous world, the caller waits for the callee. "Charge the card. If it succeeds, mark the order paid, then send the receipt." The whole story happens in one stack frame. You can debug it with a stack trace.

In an event-driven world, the caller emits a fact and walks away. "OrderPlaced happened." Whoever cares about that fact : billing, inventory, notifications, fraud, analytics. Each picks it up on their own schedule. There is no caller. There is no return value. There is no stack trace that spans the whole story.

You should reach for EDA when one of these is true:

  • You have fan-out. One event needs to trigger six independent reactions, and they don't have to happen synchronously. Order placed → reserve inventory, send confirmation, queue fulfillment, update analytics, notify the warehouse, mark the loyalty points pending.
  • You have time decoupling. The reaction can wait seconds, minutes, or hours. A nightly batch is fine. A delayed retry is fine.
  • You need resilience to spikes. Webhooks come in waves; a queue lets you smooth them out instead of crashing.
  • You have independent consumers with their own deploy cadence and ownership.

You should not reach for EDA because:

  • "Synchronous code is harder to scale." Asynchronous code is harder to reason about. You don't get free scaling. You trade it for a debugging experience that requires distributed tracing to make sense at all.
  • "It decouples our services." Decoupling is real, but the price is eventual consistency. If your business cannot tolerate "the order shows in the dashboard but the invoice hasn't appeared yet for 4 seconds", you've moved your hard problem from synchronous code to a UX you now have to invent.
  • "It's how modern systems work." Half of modern systems are still doing one synchronous database write per HTTP request and they're fine.
TypeScript orders/handlers.ts
// What an event handler actually looks like.
// Notice how much of the work is failure-handling, not business logic.

bus.on("OrderPlaced", async (evt, { ack, retryAfter }) => {
  try {
    await inventory.reserve(evt.orderId, evt.items);
    await ack();
  } catch (err) {
    if (isTransient(err)) return retryAfter("30s");
    await deadLetter(evt, err);
  }
});

Every event handler you write needs to think about idempotency (the same event will be delivered twice, eventually), ordering (events may arrive out of order), and replay (a consumer that fell behind will process old events later). None of that is exotic, but all of it is work. Pay it on purpose, not because a diagram looked tidy.

Domain-Driven Design: A Modeling Discipline, Not A Folder Layout

Most people meet DDD as "that pattern where you have entities, value objects, aggregates, repositories, and a folder called domain/". That's a caricature. The folder layout is the cheapest, most superficial part of DDD, and copying it into a project that doesn't need it just adds ceremony.

DDD is a modeling discipline. The whole point is that complex business rules deserve a vocabulary as careful as the database schema deserves indexes. You sit with domain experts. You name things the way they name things. You discover that "order" means something subtly different in fulfillment, billing, and inventory, and you stop pretending one shared Order class can serve all three. That's a bounded context. The folder structure is a side effect of taking that conversation seriously.

You probably want DDD when:

  • Business rules drift constantly and a missed nuance costs real money: pricing, billing, insurance, healthcare claims, logistics.
  • Different parts of the org use the same word to mean different things, and the codebase suffers for it.
  • The domain has its own experts, people whose job is to know the rules, not to write code.

You probably don't want DDD when:

  • The app is a CRUD frontend over a database. The "model" really is just the database row.
  • The team is small and the domain fits in one head. The overhead won't pay back.
  • You'd be importing the vocabulary without the conversations. Folders called aggregates/ and classes ending in Service don't make a system domain-driven; they make it cosplay domain-driven.
Java billing/domain/Invoice.java
// An aggregate worth the ceremony: invariants protected at the boundary.
public final class Invoice {
    private final InvoiceId id;
    private final List<LineItem> lines = new ArrayList<>();
    private InvoiceStatus status = InvoiceStatus.DRAFT;

    public void addLine(LineItem line) {
        if (status != InvoiceStatus.DRAFT) {
            throw new InvoiceLocked(id);
        }
        lines.add(line);
    }

    public void issue() {
        if (lines.isEmpty()) throw new EmptyInvoice(id);
        status = InvoiceStatus.ISSUED;
    }

    public Money total() {
        return lines.stream().map(LineItem::amount).reduce(Money.ZERO, Money::plus);
    }
}

That class earns its weight: you cannot mutate an issued invoice, you cannot issue an empty one, and the rule lives next to the data it protects. If your domain doesn't have rules like that, where every "entity" is just a row with getters, DDD will feel like paperwork, because it is.

The Patterns Compose, And They Should

The cleanest mental model is to stop thinking of these as competing options. They aren't.

A modular monolith can use MVC at the request layer, DDD inside its richest module, and emit events to its own internal bus. When that monolith outgrows itself, you can carve one module out into a microservice without rewriting the rest. The microservice can speak to the monolith over events for the parts where eventual consistency is acceptable, and over synchronous APIs for the parts where it isn't. The DDD bounded contexts you drew at the start become the natural service boundaries later, because they were always real boundaries. You just hadn't paid for the network yet.

The reason "what should we use?" is the wrong question is that the answer is almost always "several of these, in the places where each one earns its keep". MVC at the edge. Modules in the middle. DDD where the rules are dense. Events where time decouples. Microservices when, and only when, the org is too big to share a repo.

The One-Question Test

If you take only one thing from all of this, take the question. Before you draw a single box on a whiteboard, before you open the framework's CLI, before you propose splitting anything. Ask:

"What painful behavior am I trying to make go away, and is the pattern I'm reaching for the cheapest way to make it go away?"

If you can't name the painful behavior, you're not solving a problem. You're decorating one. Architecture chosen by trend always becomes the next team's problem to escape.

Pick the pressure first. The pattern follows.