Layered Architecture

A refund endpoint I inherited was forty lines long and did five jobs. It read req.params, ran two SQL queries, decided whether the order was still refundable, updated a row, and shaped the JSON that went back. One function, all of it. And it worked, which is the trap.

Then someone renamed created_at to placed_at in a migration, and four unrelated endpoints went red in CI at once, because each one had pasted the same query and the same new Date(order.created_at) arithmetic. The actual rule (refund within thirty days, unless the order already shipped) had no test at all. There was no way to reach it without booting Express and Postgres and firing a real HTTP request at the pair of them.

That is what an unlayered backend feels like day to day. Not dramatic. Just slow, and a little scary, in a way that quietly compounds until nobody wants to touch the thing.

Here is the honest part up front: your app already has layers. Every backend does, the moment it talks to HTTP on one side and a database on the other. The only question is whether the boundaries are drawn on purpose, with rules, or whether they smeared together on their own. Drawing them on purpose is most of what “architecture” means for a normal web service.

The three layers, named

Strip a request down to what actually happens and three distinct responsibilities fall out.

  • Presentation (the API layer). HTTP lives here and only here. Routes, reading req, writing res, status codes, turning your objects into JSON. This layer knows the web. See REST resource design for what a good shape looks like.
  • Domain (the business layer). The rules and the use cases. “You can refund within thirty days.” “A cart over 50 gets free shipping.” “An invoice cannot go negative.” This is the part that would still be true if you deleted the web and the database and ran it on paper.
  • Data access. Talking to the store. Queries, the ORM, the connection pool. See ORMs and query builders. Nothing in here should have an opinion about your business.

Bigger apps slip a service layer between presentation and domain to do orchestration: load these two things, apply this rule, save the result, enqueue that job. On a small app the service and the domain are basically the same file, and that is fine.

The one rule that makes it work

Layers by themselves buy you nothing. A folder called domain/ that imports your ORM and reads req.headers is not a domain layer, it is the same mud in three buckets. The value comes entirely from one constraint.

Dependencies point one direction: downward. Presentation calls domain. Domain calls (or is handed) data. Never the reverse. Your data layer must not import a route, and your domain must not import Express or your ORM’s types.

forbiddenknows HTTPno HTTP, no SQLknows SQLPresentation / APIroutes, request and response, statusDomain / Servicethe business rules and use casesData accessrepositories, SQL, the ORMcallscalls
Three layers, one rule: each layer may call the one below it, and never the one above.

Why this exact direction and not some other? Because the outer layers change for annoying, external reasons and the inner one changes for reasons you actually care about. HTTP frameworks come and go. You might move off Express, swap Postgres for something else, put GraphQL in front. The thirty-day refund rule does not change because you switched web frameworks. Point the arrows down and a storm at the edges cannot reach the middle. Point even one arrow up and your business rules now depend on the framework you were hoping to replace.

The payoff is not abstract. It is testability, and it is the whole reason to care.

The refactor, one layer at a time

Here is the tangle from the opening, cleaned up but recognisable. This is an Express 5 handler doing everything.

// routes/orders.js (one function, five jobs)
router.post("/orders/:id/refund", async (req, res) => {
  const { rows } = await pool.query(
    "select * from orders where id = $1",
    [req.params.id],
  );
  const order = rows[0];
  if (!order) return res.status(404).json({ error: "not_found" });

  // the business rule, live, wedged between two SQL calls
  if (order.status === "shipped") {
    return res.status(409).json({ error: "already_shipped" });
  }
  const ageDays = (Date.now() - new Date(order.created_at)) / 86_400_000;
  if (ageDays > 30) {
    return res.status(409).json({ error: "window_closed" });
  }

  await pool.query("update orders set status = 'refunded' where id = $1", [
    req.params.id,
  ]);
  res.json({ ok: true, id: order.id });
});
one route handlerparse params and req.bodyrule: reject after 30 daysdomain rule, trapped inside HTTPSELECT then UPDATE ordersdata access, trapped inside HTTPres.status().json()to test one line of logic,you must first boota web servera database
Everything lives in the handler, so testing the refund rule means starting a web server and a database first.

Now pull it apart. Start in the middle, because the middle is the point.

The domain: a pure function

The refund rule has nothing to do with HTTP or SQL. It is a decision about an order. So write it as a function that takes an order and returns a decision, and give it its own vocabulary instead of the database’s.

// domain/refund.js (no framework, no SQL, no imports from the edges)
const REFUND_WINDOW_DAYS = 30;
const DAY_MS = 86_400_000;

export function checkRefund(order, now = Date.now()) {
  if (order.status === "shipped") {
    return { ok: false, reason: "already_shipped" };
  }
  if ((now - order.placedAt) / DAY_MS > REFUND_WINDOW_DAYS) {
    return { ok: false, reason: "window_closed" };
  }
  return { ok: true };
}

Look at what this function does not import. Not pg, not express, not your ORM. It takes plain data and passes now in as an argument instead of reaching for the clock, so a test can pretend it is any date it likes. It speaks order.placedAt, its own name, not the database’s created_at. That last choice is small and it matters, and we will see why in a minute.

The data layer: a dumb repository

The repository knows one thing: how to move rows in and out of the store. It holds no rules. In idiomatic JavaScript it does not need to be a class either. A factory function that closes over the pool does the job and reads cleaner.

// data/orders.js (talks to the DB, knows nothing about refunds)
export function makeOrderRepo(pool) {
  return {
    async findById(id) {
      const { rows } = await pool.query(
        "select id, status, created_at from orders where id = $1",
        [id],
      );
      const row = rows[0];
      if (!row) return null;
      // map the DB row into the shape the domain speaks
      return { id: row.id, status: row.status, placedAt: row.created_at.getTime() };
    },
    async setStatus(id, status) {
      await pool.query("update orders set status = $1 where id = $2", [status, id]);
    },
  };
}

The mapping line is the whole trick. created_at (a Date from the driver, in snake_case) becomes placedAt (milliseconds, in the domain’s language) right here, at the edge, and nowhere else. The database’s naming stops at the repository door. If someone renames the column tomorrow, this one line changes and the domain never notices.

The service: orchestration

Something has to load the order, run the rule, and save the result. That is the service. It is glue, and glue is allowed to be boring.

// service/refund-order.js (load, decide, persist)
import { checkRefund } from "../domain/refund.js";
import { DomainError } from "../domain/errors.js";

export function makeRefundOrder({ orders }) {
  return async function refundOrder(orderId, now = Date.now()) {
    const order = await orders.findById(orderId);
    if (!order) throw new DomainError("not_found");

    const decision = checkRefund(order, now);
    if (!decision.ok) throw new DomainError(decision.reason);

    await orders.setStatus(orderId, "refunded");
    return { id: order.id, status: "refunded" };
  };
}

The service throws domain errors, plain objects that carry a code and know nothing about HTTP:

// domain/errors.js
export class DomainError extends Error {
  constructor(code) {
    super(code);
    this.code = code;
  }
}

The controller: parse, call, respond

What is left for the route handler? Three moves. Read the request, call the service, send the answer. That is a thin controller, and thin is the goal.

// routes/orders.js
router.post("/orders/:id/refund", async (req, res, next) => {
  try {
    const result = await refundOrder(req.params.id);
    res.json(result);
  } catch (err) {
    next(err); // let one place turn domain errors into status codes
  }
});

The status-code knowledge lives in exactly one spot: an error-mapping middleware at the end of the chain. This is the onion model doing what it is good at, catching everything on the way out.

// one place that speaks both languages: domain code -> HTTP status
const STATUS = { not_found: 404, already_shipped: 409, window_closed: 409 };

app.use((err, req, res, next) => {
  res.status(STATUS[err.code] ?? 500).json({ error: err.code ?? "internal" });
});

Four files where there was one. That is real cost, and on a tiny app it would be silly. But watch what the refund rule costs to test now.

// no server, no database, no mocks, no async
import { checkRefund } from "../domain/refund.js";
const days = (n) => n * 86_400_000;

test("the refund window closes after 30 days", () => {
  const order = { status: "paid", placedAt: Date.now() - days(31) };
  expect(checkRefund(order).ok).toBe(false);
});

test("a shipped order cannot be refunded", () => {
  expect(checkRefund({ status: "shipped", placedAt: Date.now() }).ok).toBe(false);
});

Those run in under a millisecond, all of them, with nothing booted. The rule that was previously unreachable is now the easiest thing in the codebase to check. That trade, some indirection in exchange for rules you can actually test, is the deal layering offers. See testing for what to do with the seam once you have it.

How a request actually moves

Once the layers exist, a single request runs a tidy round trip: down through each layer to the database, then back up, getting reshaped at the two outer edges.

requestresponseController: parse, call, respondService and domain ruleRepositoryDatabasedomain result to JSONdomain objects both sides, no mapDB row to domain object
The request travels down through thin layers and the response back up, with a mapping step at each outer edge.

The two mapping steps sit at the two outer boundaries, and only there. The repository maps the raw row into a domain object on the way up. The controller maps the domain result into the response shape on the way down. In between, service and domain pass the same plain objects around, so there is nothing to translate. Mapping is a chore, but it is a cheap chore in JavaScript: object spread, a pick helper, or structuredClone when you want a defensive copy that shares no references with the original.

Where the logic actually goes

This is the part people get wrong, and they get it wrong in both directions. Two failure modes, mirror images of each other.

The fat controller. Business rules pile up in the route handler because that is where the request already is. It feels efficient. Then the same rule is needed by a background job, or a CLI script, or a second endpoint, and it cannot be reused because it is fused to req and res. Now it gets copy-pasted, and the copies drift.

The smart repository. The other direction: a rule sneaks into SQL. where created_at > now() - interval '30 days' looks tidy, and now your refund policy is expressed in a query string that no unit test will ever see and no reader will recognise as a business decision. When the rule changes, you go hunting through data access code to find it.

Both come from the same slip: putting logic where the data happens to be instead of where the logic belongs. The fix is a habit, held per responsibility.

Two edge cases earn a sentence each, because they trip people up. Validating the shape of a request (is id a UUID, is amount a positive number) belongs at the edge, before the domain ever sees the data. That is input validation, and it is a presentation concern with a domain flavour. But deciding whether this user may refund this order is authorization, and where it lives depends on whether the decision needs domain data. A blanket “admins only” check is edge work. “You can only refund your own orders” needs the order loaded, so it lives in the service, next to the rule it guards.

Try the placement yourself. Assign each responsibility to the layer it belongs in and watch the feedback. A couple of them are deliberately sneaky.

interactiveSort each responsibility into its layer

The leak that couples everything

Now the anti-pattern that quietly destroys the value of every boundary you just drew. It is one line, and it is everywhere.

router.get("/orders/:id", async (req, res) => {
  const { rows } = await pool.query("select * from orders where id = $1", [req.params.id]);
  res.json(rows[0]); // ship the raw row straight to the client
});

That row never got mapped. It travelled from the database, through however many layers you have, out to the client, unchanged. Modern ORMs make this even easier to do by accident, because Prisma and Drizzle hand you plain objects that serialise cleanly, so res.json(row) just works. It works right up until it does not.

response JSONid, status, created_atpassword_hashorders rowid, status, created_atpassword_hashcontroller: res.json(row)service: forwards itrepository: returns raw rowcolumns become your APIadd a column, it leaksrename one, the API breaksswap the ORM, touch it all
The stored row rides straight through every layer to the client, so your database schema becomes your public API, secrets and all.

Count the ways this bites. Your column names are now your public API contract, so created_at and every snake_case decision leaks to clients who will build against them. Add a password_hash column next sprint and it ships to every consumer automatically, a genuine security incident born from a select *. Rename a column to something clearer and you break an API you did not know you were promising. And because the ORM’s result type now travels through every layer, the day you want to swap the ORM you have to touch all of them, which was exactly the coupling layering was supposed to prevent.

The fix is the mapping you already saw, applied at the outbound edge. The controller shapes a response on purpose.

// presentation owns the response shape, deliberately
function toOrderResponse(order) {
  return { id: order.id, status: order.status, placedAt: order.placedAt };
}

router.get("/orders/:id", async (req, res, next) => {
  const order = await getOrder(req.params.id);
  if (!order) return next(new DomainError("not_found"));
  res.json(toOrderResponse(order)); // only what you chose to expose
});

Now the wire format is a decision, not an accident. A new database column changes nothing about the response until you add it to toOrderResponse on purpose. This is the same instinct behind the adapter pattern: a deliberate seam so the shape on one side cannot dictate the shape on the other.

Match the ceremony to the size

Every layer is a boundary, and every boundary is indirection: another file to open, another hop to trace, another mapping to write. That is not free. On a two-endpoint side project, splitting one clear function into a controller, a service, a domain module, and a repository is pure overhead, and anyone who tells you it is “correct” regardless of size is selling architecture as religion. See what a pattern actually is for the general version of this argument.

one handlerdoes it allfine at this sizecontrollerservice and domainrepositoryworth the indirectionsmall, short-livedlarge, long-livedthe crossoversplit when you feel one of these:can’t test a rule alonea rename breaks routessame query, five copies
Layers are ceremony you buy when the app grows into needing them. The signals below are the receipt.

So do not layer on aspiration. Layer on pain. The signal to pull a rule out into its own function is that you tried to test it and could not without a server. The signal to add a repository is a query copied into a fifth handler. The signal to add response mapping is a column you would not want a client to see. Each layer is a response to a specific hurt, and if you have not felt the hurt yet, you are allowed to wait.

The direction of travel is worth naming, though, because it does not stop here. Keep pushing the “domain depends on nothing external” rule and you arrive at ports and adapters, where the core defines the interfaces it needs and every piece of infrastructure becomes a swappable plug at the edge. And the seam that made the repository injectable, passing the pool in rather than reaching for a global, is the front door to dependency injection. Layered architecture is the on-ramp to both. It is also, for the large majority of services, exactly as far as you need to go.

Summary

  • Your backend already has layers. The choice is whether the boundaries are deliberate or accidental. Deliberate is the difference between a codebase you can change and one you are afraid of.
  • The three are presentation (HTTP, req/res, status codes), domain (the business rules and use cases), and data access (queries and the ORM), with a service layer for orchestration once an app is big enough to want one.
  • One rule carries all the weight: dependencies point downward. Presentation calls domain calls data, never the reverse. The domain imports neither your web framework nor your ORM, which is what makes it testable and reusable.
  • Keep controllers thin (parse, call, respond), keep repositories dumb (persistence, no rules), and put the rules in the domain as pure functions. A rule you can state without mentioning a framework or a table belongs in a function that mentions neither.
  • In JavaScript a layer is a module or a folder, not a class hierarchy. Factory functions and closures do the wiring; reach for a class with #private fields only when an object has a real invariant to protect.
  • The two mirror-image mistakes are the fat controller and the smart repository. Both come from putting logic where the data is instead of where the logic belongs.
  • The worst leak is a raw row travelling to the client. It welds your database schema to your public API, ships secrets by accident, and breaks on a rename. Map at the outbound edge so the wire format is a decision, not a select *.
  • Layering costs indirection and mapping. Match the ceremony to the size: overkill for a tiny app, essential for a growing one. Add each layer in response to a specific pain, not on principle.
  • When to use it: any service with real business rules you want to test and change safely. When to skip it: a small, short-lived CRUD app where the table is the resource. This is the on-ramp to ports and adapters and dependency injection, and usually as far as you need to travel.