Skip to content

9.9.6 — App Architecture: MVC, Layers, Repository, DI, Hexagonal — Honestly

Two codebases, both broken, in opposite directions.

The first has twelve endpoints and seven folders. Adding a field means editing five files, four of which only copy the value from one object shape into another. Nobody can find where anything happens.

The second has two hundred endpoints and no folders worth mentioning. Every route handler contains its own validation, its own business rules, its own SQL, and a call to a payment provider. The same discount calculation exists in four places and they no longer agree.

Both teams believed they were being sensible. The first followed an architecture guide written for a system twenty times its size. The second kept shipping and never stopped to notice the handlers had become the application. This page is about knowing which mistake you are closer to.

1. The baseline: routes, services, data — and why

Start from the failure that motivates all of it. Business logic in the route handler:

javascript
router.post("/orders", async (req, res) => {          // ❌ everything in the handler
  const items = req.body.items;                        // …validation ad hoc
  const total = items.reduce((s, i) => s + i.price * i.qty, 0);   // …domain math
  const { rows } = await pg.query("INSERT INTO orders …");        // …SQL inline
  await stripe.charges.create({ amount: total });                 // …vendor inline
  await sendgrid.send({ /* … */ });                               // …more vendor
  res.status(201).json(rows[0]);
});

Ten lines, four separate problems.

You cannot run this logic without running everything. Checking whether the total is computed correctly requires an HTTP request, a live database, a payment provider, and an email service. The rule you want to examine is three lines long and it is welded to four systems.

Nothing else can use it. When someone needs to place an order from a scheduled job, a background worker, or an internal admin tool, none of this is reachable — the operation exists only as a response to an HTTP request. So it gets written again, slightly differently.

The business rule is invisible. How a total is computed is a fact about your company, and here it lives in the middle of a route handler, in a reduce call, sandwiched between JSON parsing and SQL. The next handler that needs a total will not find this one and will write its own.

Changing vendors means editing business logic. The payment provider's name appears in the same function as the domain rules, so swapping it means opening a file whose subject is orders. That is the dependency-inversion problem from 9.3.9 in its most ordinary form.

The fix is three roles, and this is the smallest structure worth having. Most services never need more than it:

  • Route/controller — HTTP only: read the request, call one service function, map the result to a status and body. It knows about req/res and nothing about the domain's internals.
  • Service (use case) — the business operation, in domain language: placeOrder(customer, cart). No req, no res, no SQL, no vendor SDKs — which is precisely what makes it testable with plain objects and reusable from a worker or a CLI.
  • Repository / gateway — data and external systems behind ports the service owns (9.3.1): orders.save(order), payments.charge(amount, token).
typescript
// routes/orders.ts — thin, HTTP-shaped
router.post("/", validate(CreateOrder), asyncHandler(async (req, res) => {
  const order = await orderService.place(req.user, req.body);     // ONE call
  res.status(201).location(`/orders/${order.id}`).json(toDto(order));
}));

// services/order-service.ts — the use case, framework-free
export function makeOrderService({ orders, payments, clock }: Deps) {   // DI by argument
  return {
    async place(user: User, input: CreateOrderInput): Promise<Order> {
      const order = Order.create(user.id, input.items, clock.now());     // domain rules (9.2)
      const charge = await payments.charge(order.total, input.token);    // port, not Stripe
      order.markPaid(charge.id);                                          // entity invariants
      await orders.save(order);
      return order;
    },
  };
}

That is layered architecture in its honest minimum: HTTP → use case → data, with dependencies pointing inward. Nearly every "which architecture?" debate is about what to add on top of this, and the answer for most services is nothing.

2. MVC, layered, and the Repository question

MVC in a JSON API is mostly vestigial vocabulary: there is no View (the JSON serializer isn't one), so "MVC" degenerates to "controllers and models" — and when the Model is an ORM class, you get the anemic model + fat controller shape (9.2.2). Use the words if your team does, but know the mapping: controller = the route layer above, model = your domain entities (which should hold behavior, not just columns), and the missing piece MVC never names — the service/use-case layer — is the one doing the real work. Full MVC earns its name in server-rendered apps (Rails, Django), where the View is real. MVC vs layered architecture in Express apps. [EQ-228b]

A repository is an object that behaves like a collection of your domain objects — findById, save, findByCustomer — while hiding both which database sits behind it and how the query is written.

What it genuinely buys you. Your service code stops knowing about storage, so it can be exercised against a simple in-memory stand-in. All the query knowledge for one kind of object lives in one file, instead of SQL appearing in nine handlers. And putting a cache in front, or moving to a different store, happens behind the interface without touching anything that expresses a business rule.

Now the costs, honestly. The first is small: a layer of methods that only forward. save(x) { return db.orders.insert(x) } adds a file and a hop and no meaning.

The second is the one that actually catches teams out, and it arrives with query pressure. Real screens need joins, filtered projections, and pagination. So either your repository sprouts a method per screen — and you end up with findActivePaidOrdersByRegionSortedByTotal — or you expose the query builder through the interface, at which point callers are writing storage-specific queries again and the abstraction has quietly stopped existing.

Where that leaves you: a repository is worth it when you have a domain model with rules worth protecting. When your service is mostly reading and writing table rows, a well-organised data-access module — or your ORM used directly, in one clearly marked layer — is the more honest design than a repository that renames the ORM and calls it architecture (9.4.1).

3. Dependency injection in Express, priced

9.4.24 gave the ladder; here it is in Express clothing. The rung that carries most services is factory functions plus a composition root (9.4.6):

typescript
// app.ts — the composition root: everything constructed once, wired explicitly
const pool     = new Pool(config.db);
const orders   = makeOrderRepository(pool);
const payments = new StripeGateway(config.stripeKey);         // adapter (9.4.3)
const orderService = makeOrderService({ orders, payments, clock: systemClock });
app.use("/api/orders", makeOrderRouter({ orderService }));    // routers receive deps too

There is no container here, no decorators, and no reflection. Just functions that return objects, wired together in one file, with the TypeScript compiler checking every connection (3.7). Pass a wrong shape and the build fails before anything runs. Anywhere you want a different implementation, you build the same graph with different arguments.

When is a real container worth it? Two conditions, and you want both. The graph has to be big enough that wiring it by hand is genuinely tedious, and the object lifetimes have to multiply — services created per request, or one instance per tenant (9.4.6). A container manages lifetimes for you, which is the thing hand-wiring is actually bad at. If you are already inside a framework that has one, use it; fighting the framework is worse than either option.

When is it not? Twelve services wired in twenty lines do not need runtime resolution, and the price is real. Wiring mistakes stop being compile errors and become errors thrown when something is resolved, which may be during a request, in production. And the container's conventions become architecture nobody on your team chose (9.4.24). Dependency injection in Node/Express applications. [EQ-232b]

4. Clean / Hexagonal — what's real, what's ceremony

Hexagonal architecture (ports and adapters; Clean Architecture is a close cousin with more named rings) has exactly one essential idea, and you already have it: the domain defines the interfaces it needs; infrastructure implements them; dependencies point inward (9.3.9's DIP with folder discipline). Everything else — the ring diagram, the strict layer-crossing rules, entity/use-case/interface-adapter naming — is packaging.

DOMAIN + USE CASESentities, invariants,services — zero imports outDRIVING adaptersHTTP routes · CLI · queue consumerDRIVEN adapterPostgresOrderRepositoryDRIVEN adapterStripePaymentGatewayPORTS (interfaces)declared BY the domain, in its language
Figure 1 — Ports and adapters. All arrows point inward: HTTP, CLI, and queue consumers drive the same use cases; databases and vendors are driven through ports the domain declares in its own vocabulary. The shape's whole payoff is that the center compiles and tests with zero infrastructure.

Three things here are genuinely valuable.

The domain code imports no framework and no vendor library at all. That sounds like a purity rule and it has a very practical effect: that code loads and runs instantly, with nothing to start up, so the rules of your business are the fastest thing in the codebase to work with.

The ports are named in your vocabulary, not the vendor's. A port called charge(amount, token) describes what your business needs. A port called createStripePaymentIntent is a payment provider's API with an interface drawn around it, and it will not survive replacing the provider (9.3.1 has the test for telling these apart).

And HTTP becomes just one way in. The same use case can be driven by a queue consumer, a scheduled job, or a command-line tool, with no changes, because none of them are special to it.

Three things are usually ceremony.

Mapping objects at every layer boundary, so the same order exists as four nearly identical shapes with hand-written conversions between them. Adding one field then means editing five files, none of which contain a decision.

Interfaces created for one implementation because the shape looked asymmetric without them.

And folder structures so elaborate that finding the code for "place an order" takes a diagram. If a new team member cannot locate a feature in under a minute, the structure is costing more than it returns.

A rough sizing guide. Below about ten endpoints, the baseline from section 1 is your architecture; anything more is decoration. Between roughly ten and fifty, with real rules to protect, add ports for the dependencies most likely to change — payments, email, file storage — and let the rest talk to the database directly. Past that, or when several different things genuinely drive the same use cases, or when the domain itself is the hard part of the product, the full discipline starts paying for itself.

5. The expert lens

Structure should follow how complicated the domain is, not how sophisticated the team wants to look. A service that reads and writes five tables has no rules to protect, so layers there are pure overhead — thin handlers over a data-access module is the honest design (9.1). A service where the rules are the product earns every layer it has, because those rules need to be readable and runnable on their own.

There is a better question than "what architecture should we use", and it is: where do the changes come from? If most of your churn is vendors being swapped and integrations changing, you want ports. If it is business rules changing, you want a domain model rich enough to hold them. If it is simply more endpoints doing more of the same thing, you want nothing except routers that all look alike.

Adopt a pattern when something pushes you to, and write down what that push would be. The most useful document a team can keep is short and describes what they have deliberately not built, with the trigger for building it: "no repository yet — we use the data module directly until there is a second store"; "no dependency injection container until we have request-scoped instances".

Two lines like that do a lot of work. They turn what would look like an omission into a decision, and they hand the next engineer the reasoning instead of leaving them to guess it from the code.

Consistency beats optimality. Twenty-five endpoints in one style are easier to work on than fifteen in the "right" style and ten in the newest one. When you change the architecture, migrate or mark; a codebase with three coexisting philosophies costs more than any of them would have alone — which is the real reason to decide deliberately, early, and in writing.

What the interviewer will push on

Architecture questions in an interview are really judgement questions. They are checking whether you can size a solution to a problem instead of reciting the largest one you know.

"How would you structure an Express app?" The trap is answering with the most elaborate thing you have read about. Start from the baseline — thin routes, a service layer in domain language, data access behind an interface — say that most services never need more, and then name what would make you add more. A candidate who describes hexagonal architecture for a twelve-endpoint service has told the interviewer they cannot calibrate.

"Why not put the logic in the route handler?" They want the concrete costs, not "separation of concerns". Give three: you cannot run the rule without HTTP and a database, nothing else in the system can reuse it, and swapping a payment provider means editing a file about orders. Naming reuse by a background job is the detail that shows you have hit this in real work.

"Do you use the repository pattern?" The good answer includes when you would not. Say what it buys — storage-agnostic services, query knowledge in one place — and then name the failure: under query pressure it either grows a method per screen or leaks the query builder, and at that point it is a rename rather than an abstraction. Over plain table CRUD, a data module is more honest.

"How do you do dependency injection in Node?" Factory functions and one composition root, with the compiler checking the wiring. Then say what would make you reach for a container: a large graph and multiplying lifetimes, such as per-request or per-tenant instances. The weak answer treats a container as the professional option and hand-wiring as the beginner one.

"When is Clean Architecture worth it?" They want to hear you separate the idea from the packaging. The idea — the domain declares the interfaces it needs and nothing points outward — is cheap and often worth it. The packaging — object mapping at every boundary, interfaces with one implementation, deep folder taxonomies — is where the cost lives. Tie the answer to domain complexity and to how many things drive the same use cases.

Volunteer this one, because nobody asks: say that consistency beats correctness here. Twenty-five endpoints written the same way are easier to work in than fifteen written the "right" way and ten written the newest way. A codebase with three coexisting philosophies costs more than any one of them would have on its own, which is the real argument for deciding early and writing the decision down — not that the first choice is optimal, but that changing it halfway is worse than either end state.

Next: 9.9.7 — running it for real: graceful shutdown, health and readiness endpoints, process managers and clustering, a reverse proxy in front, and the deployment checklist.

Recall

  • Baseline (most services stop here): thin route/controller (HTTP only) → service/use case (domain language, framework-free, testable with plain objects, reusable from CLI/worker) → repository/gateway (ports the service owns). Motivating failure: handlers holding validation + domain math + SQL + vendor SDKs.
  • MVC in a JSON API is vestigial (no View); its "model" must not be an anemic ORM row, and the layer it omits — the use case — is where the work is. Full MVC fits server-rendered apps.
  • Repository: hides store choice, concentrates query knowledge, enables fast fake-based tests — but leaks under query pressure (method explosion or exposed query builder). Worth it with a real domain model; ceremony over plain CRUD.
  • DI: factory functions + a composition root carry most Express services (compile-time-checked wiring, no magic). Climb to a container only for large graphs and multiplying lifetimes (request/tenant scopes) — resolve-time errors and framework gravity are the price.
  • Hexagonal/Clean = one real idea (domain declares ports, infrastructure implements, arrows point inward — the domain compiles with zero infrastructure) plus packaging. Ceremony to avoid: DTO mapping at every ring, one-implementation interfaces, maze folders. Size rule: <10 endpoints → baseline; 10–50 → ports for volatile deps; beyond / rich domain / multiple driving adapters → full discipline.
  • Lens: ask where change comes from (vendor → ports, rules → rich domain, endpoints → consistent routers); record promotion triggers in an architecture note; consistency beats optimality.

Self-test: List the four defects of business logic in a route handler. What does the service layer forbid itself, and why does that make it valuable? Give the repository's two honest costs. When does a DI container earn its price? Name hexagonal's one real idea and three ceremonies.

Quiz Bank

FoundationalWhy is business logic in a route handler a problem — and what is the minimum structure that fixes it?

Four concrete defects: (1) untestable — exercising the rule requires HTTP, a database, and every vendor SDK, so the fast unit tests that would pin the domain don't exist; (2) unreusable — the same operation invoked by a queue consumer, a CLI, or a scheduled job must be rewritten, and the copies diverge; (3) invisible domain — "how a total is computed" lives in a handler rather than a named concept, so it's duplicated in the next handler and nobody can find the rule (9.1's cohesion failure); (4) vendor coupling at the core — swapping Stripe or the ORM edits business code (9.3.9's DIP inverted the wrong way).

Minimum fix — three roles: a route/controller that only reads the request, calls one service function, and maps the result to status+body; a service/use case expressed in domain language with no req/res/SQL/vendor imports; and repositories/gateways behind ports the service declares. That's layered architecture's honest minimum, and for most services it's also the maximum needed — everything else in this chapter is about when to add more.

FoundationalAssess the Repository pattern honestly: what it buys, what it costs, and when it's ceremony.

Buys: storage-agnostic services (swap Postgres, add a cache, introduce read replicas behind one interface); fast tests (an in-memory fake replaces the database, so the domain suite runs in milliseconds — 9.8); concentrated query knowledge (one file per aggregate instead of SQL smeared across handlers); and a natural home for aggregate-consistency rules.

Costs: an indirection layer that is often filled with pure pass-throughs (save(x) { return db.insert(x) }), and — the genuine failure mode — leakage under query pressure: reports, joins, projections, and pagination push you toward either a method explosion (findActivePaidOrdersByRegionSortedByTotalPaginated) or exposing the query builder through the interface, at which point the abstraction has evaporated while the ceremony remains.

Ceremony when: the service is CRUD over tables with no invariants to protect — then a well-organized data module (or the ORM used directly in one layer) is more honest, and the repository is a rename (9.4.1's pattern-fever test: structure without tension).

Worth it when: you have a real domain model whose invariants matter, multiple consumers of the same data operations, or a test suite whose speed depends on faking persistence. A pragmatic middle many teams land on: repositories for aggregates that carry invariants, plus a separate read/query module for reporting-shaped access (a CQRS-lite split — Part 10.8).

AppliedShow the composition-root wiring for an Express service and explain why it beats importing dependencies directly in modules.
typescript
// app.ts — one place where the graph exists
const pool = new Pool(config.db);
const orders = makeOrderRepository(pool);
const payments = new StripeGateway(config.stripeKey);
const orderService = makeOrderService({ orders, payments, clock: systemClock });
app.use("/api/orders", makeOrderRouter({ orderService }));

Every module is a factory taking its dependencies as arguments and returning its interface; nothing imports a database client, a vendor SDK, or a singleton. Why it beats module-level imports (import { db } from "../db" inside a service): testability — the same factory constructs with fakes, no module mocking or global patching; explicit dependencies — a module's needs are visible in one signature rather than scattered across imports, so coupling is reviewable (9.1); lifetime control — the root decides what's shared, what's per-tenant, what's per-request (9.4.6's singleton critique, avoided by construction); compile-time verificationtsc checks every wire, so a mis-wired graph fails the build rather than resolve-time (3.7); and honest startup order — the file reads as the system's dependency order, which is also where config validation and fail-fast belong. The health check on any Express codebase: grep for new and vendor imports outside the composition root; concentration means disciplined, scattering means the graph is implicit.

InterviewWhat is Hexagonal architecture's one real idea, and which parts of it are commonly ceremony?

The real idea: the domain declares the interfaces it needs, in its own vocabulary, and infrastructure implements them — so all source-code dependencies point inward and the domain package imports no framework, no ORM, no vendor (9.3.9's DIP with folder discipline). Two consequences make it worth the name: the core compiles and tests with zero infrastructure (millisecond suites, no containers), and HTTP becomes merely one driving adapter — the same use case is invoked by a queue consumer, a CLI, or a test without modification, while databases and vendors are driven adapters behind ports.

Commonly ceremony: DTO mapping at every ring boundary (four representations of one order, hand-mapped, so adding a field is a five-file change — pay this only where the representations genuinely differ, e.g. a public API contract vs the domain model); interfaces created for symmetry with exactly one implementation and no test-fake need (9.3.4's speculative-plug-point caveat); rigid ring-crossing rules enforced by lint on a codebase whose domain is CRUD; and folder taxonomies deep enough that locating "place order" needs a guide. The calibration: adopt the arrow rule and the port vocabulary always; adopt the rest when the domain's complexity — not the diagram's elegance — demands it.

StaffTwo teams in your org: Team A runs a 9-endpoint CRUD service with full Clean Architecture (4 layers, DTOs at each boundary, DI container); Team B runs a 140-endpoint billing service with logic in route handlers and direct ORM calls. Both complain about velocity. Give each a plan, and the org policy that prevents both patterns.

Team A — over-structured for the domain. Their velocity cost is per-change ceremony: adding a field touches four DTOs and three mappers, and every new engineer spends a week learning a taxonomy that protects a domain with no invariants (9.1's accidental complexity, institutionalized). Plan:

collapse deliberately — merge the DTO rings (keep exactly two representations: the public API contract and the internal model — the boundary that genuinely differs, 9.6.1); delete interfaces with one implementation and no fake (9.3.9); keep the ports for genuinely volatile dependencies only (payment vendor, email); replace the DI container with factory wiring at a composition root if lifetimes are all singletons (9.4.24's ladder, descending). Measure: files touched per typical change, before and after.

Team B — under-structured for the domain. Billing is rules — proration, tax, dunning, refunds — and those rules are currently untestable, duplicated, and un-auditable. Plan: extract by hotspot, not wholesale (9.1): characterize the top-churn endpoints, lift their logic into named use-case functions with plain-object tests, introduce value objects for money and periods (9.2.1), and add ports only where vendors are (payment provider, tax service). No big-bang re-architecture; the endpoint layer stays until the domain layer is proven. Measure: escaped billing defects and time-to-change on the extracted rules.

Org policy that prevents both: (1) architecture is chosen per service by domain complexity and change source, and recorded in a one-page note that lists what was not built and the trigger for revisiting ([9.9.6]'s promotion-trigger discipline); (2) a shared baseline template (thin routes → use cases → data access, error funnel, hardening — [9.9.5]) so every service starts identical and consistent, and deviations are additive and argued; (3) review asks the diagnostic question on every proposal — where does the change come from? — and rejects both "we should add layers" and "we'll refactor later" without an answer.

The org sentence: both teams have the same disease — architecture chosen by ideology rather than by the shape of their change — and the cure is a written, revisited decision, not a house style imposed on domains that differ.

Flashcards

FlashThe baseline three roles

Route (HTTP only) → service/use case (domain language, no req/res/SQL/vendors) → repository/gateway (ports the service owns). Most services need nothing more.

FlashMVC in a JSON API

Vestigial — no View; "model" must not be an anemic ORM row; the missing layer (use case) does the real work. Full MVC fits server-rendered apps.

FlashRepository verdict

Buys: store-agnostic, fast fake tests, concentrated queries. Costs: pass-through ceremony, leaks under query pressure (method explosion / exposed builder). Worth it with real invariants.

FlashDI in Express

Factory functions + composition root = compile-checked wiring, no magic. Container only for big graphs + multiplying lifetimes (request/tenant scopes).

FlashHexagonal, honestly

Real: domain declares ports in its language; arrows point inward; core tests with zero infra; HTTP is one driving adapter. Ceremony: DTOs per ring, one-impl interfaces, maze folders.

FlashThe diagnostic question

Not "which architecture?" but "where does change come from?" — vendor churn → ports; rule churn → rich domain; endpoint churn → consistent routers.

Scenario Drill

DrillYou're founding engineer on a new Express service: subscription billing for B2B customers (plans, proration, invoices, dunning, tax via a vendor, payments via a PSP), expected to run 5 years and be worked on by teams you'll never meet. Choose the architecture, justify every inclusion AND every omission, and specify what you write down on day one.

Read the domain first: this is a rules product. Proration, tax rules, dunning schedules, and invoice arithmetic are the reason the service exists, they change with regulation and pricing experiments, and they must be auditable years later — which is the profile that earns real structure ([9.9.6]'s size rule, from the "domain is the product's complexity" branch).

Include, with justification: (1) A framework-free domain packageMoney and BillingPeriod value objects (9.2.2/9.7.29's money law: integer minor units, exact-sum invariants), Subscription and Invoice entities guarding their own state transitions (9.4.14's machine for dunning: active → past_due → suspended → cancelled), and pure functions for proration/tax computation. It imports nothing — so five years of framework churn cannot touch the rules, and its tests run in milliseconds with property-based checks on the arithmetic. (2)

Ports for the two genuinely volatile dependenciesPaymentGateway and TaxCalculator — declared in our vocabulary, with adapters for the current PSP and tax vendor (9.3.1/9.4.7); both vendors will be replaced within five years, and both must be fake-able for tests. (3)

Use-case services (renewSubscription, issueInvoice, retryPayment) callable from HTTP and from the scheduler (dunning runs nightly) and from an admin CLI — three driving adapters is exactly the condition that justifies hexagonal's shape rather than its vocabulary. (4)

The event/ledger discipline — invoices and payment attempts are append-only records with derived balances (9.7.29's ledger lesson): billing disputes years later need the sequence, not the current row. (5) The 9.9.1–[9.9.5] baseline — standard middleware order, error funnel with a stable code registry (9.9.3), schema validation feeding OpenAPI (9.6.4), hardening module, idempotency keys on every write (9.6.3 — payments retry).

Omit, deliberately and in writing: a DI container (the graph is ~15 objects; factory wiring at a composition root is compile-checked and teaches itself — revisit if per-tenant lifetimes appear); repositories for reporting queries (aggregates get repositories; reports get a separate read module — avoiding the method-explosion leak, section 2); DTO layers beyond two representations (API contract vs domain model); microservices (one deployable until team boundaries or scaling demand otherwise — Part 10's cost is real and unearned on day one); and an events/messaging backbone (in-process orchestration until a second consumer exists — 9.4.12's boundary).

Day-one written artifacts: an ARCHITECTURE.md with the three-paragraph rationale, the dependency-arrow rule, and — most valuable to teams you'll never meet — the omissions list with triggers ("add a DI container when request-scoped services appear"; "split a service when two teams contend for the deploy"; "introduce messaging when a second consumer needs invoice events"); a code-owner map by layer; and a DECISIONS/ folder for dated ADRs. The founding sentence to leave them: the domain package is the asset, everything else is replaceable scaffolding — protect the first, and keep the second boring, consistent, and documented with its own expiry conditions.