Layered / Clean Architecture
Layered architecture splits an application into horizontal slices—each with one job—so that a change in one slice rarely ripples into the others. The classic three layers are the controller (handles HTTP, parses input, shapes responses), the service (the business rules, framework-agnostic), and the data layer (persistence behind a repository). Clean and hexagonal architecture sharpen this idea with a single rule about which way dependencies point. The result is code that is easy to test, easy to reason about, and resilient to swapping out frameworks or databases.
The three layers
Think of a request flowing inward and a response flowing back out. Each layer only talks to the one directly beneath it, and each speaks a narrower, more domain-focused language as you go deeper.
| Layer | Responsibility | Knows about | Must NOT know about |
|---|---|---|---|
| Controller | HTTP, validation, status codes, serialization | Express/Fastify, the service interface | SQL, the database driver |
| Service | Business rules, orchestration, transactions | Domain objects, repository interfaces | req/res, HTTP, framework types |
| Data (repository) | Reading and writing persistence | The database driver, table layout | HTTP, business policy |
The controller never touches the database; the service never touches res.json(); the repository never decides whether an email is “already registered.” When those rules hold, you can unit-test the service with no server running and no database connected.
The dependency rule
Clean architecture (Robert C. Martin) and the older hexagonal / ports-and-adapters style (Alistair Cockburn) agree on one principle: source-code dependencies point inward, toward the business rules. The domain and service code sit at the center and depend on nothing external. The outer rings—web frameworks, databases, message queues—depend on the center, never the reverse.
You achieve this by having the inner layer define an interface (a “port”) that the outer layer implements (an “adapter”). The service declares “I need something that can save(user)”; an outer adapter provides a Postgres implementation. At runtime you inject the concrete adapter, but at compile-and-reason time the service depends only on its own port.
The litmus test: you should be able to delete your entire
web/anddb/folders and thedomain/folder still compiles and its tests still pass. If domain code imports Express orpg, the dependency arrow is pointing the wrong way.
Structuring the project
A pragmatic Node.js layout groups files by layer (or by feature, with these layers inside each feature). Here is a layer-first layout:
src/
domain/
user.js # plain domain object + invariants
user-repository.js # PORT: the interface the service needs
services/
user-service.js # business rules, depends on the port only
data/
postgres-user-repository.js # ADAPTER: implements the port
web/
user-controller.js # HTTP in, HTTP out
routes.js
container.js # wires concrete adapters to services
server.js # boots Express, mounts routes
The domain layer at the center defines the port. Everything else depends inward on it.
// domain/user-repository.js — the port the service depends on
export class UserRepository {
async findByEmail(email) { throw new Error("not implemented"); }
async save(user) { throw new Error("not implemented"); }
}
// services/user-service.js — pure business logic, framework-free
export class UserService {
constructor(users) {
this.users = users; // any UserRepository (a port)
}
async register(email, name) {
if (!email.includes("@")) throw new ValidationError("Invalid email");
const existing = await this.users.findByEmail(email);
if (existing) throw new ConflictError("Email already registered");
return this.users.save({ email, name, createdAt: new Date() });
}
}
export class ValidationError extends Error {}
export class ConflictError extends Error {}
The controller is a thin translator between HTTP and the service. It catches the service’s domain errors and maps them to status codes—the service itself never mentions 404 or 409.
// web/user-controller.js
import { ValidationError, ConflictError } from "../services/user-service.js";
export function makeUserController(service) {
return {
async register(req, res) {
try {
const { email, name } = req.body;
const user = await service.register(email, name);
res.status(201).json(user);
} catch (err) {
if (err instanceof ValidationError) return res.status(400).json({ error: err.message });
if (err instanceof ConflictError) return res.status(409).json({ error: err.message });
throw err; // let the central error middleware handle the unexpected
}
},
};
}
Wiring it together (composition root)
Dependencies are assembled in exactly one place—the composition root—usually a small container module loaded at startup. This is the only spot that knows about every concrete class. Nothing else imports the database adapter directly.
// container.js
import pg from "pg";
import { PostgresUserRepository } from "./data/postgres-user-repository.js";
import { UserService } from "./services/user-service.js";
import { makeUserController } from "./web/user-controller.js";
export function buildContainer() {
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const userRepository = new PostgresUserRepository(pool);
const userService = new UserService(userRepository);
const userController = makeUserController(userService);
return { pool, userController };
}
// server.js
import express from "express";
import { buildContainer } from "./container.js";
const { userController } = buildContainer();
const app = express();
app.use(express.json());
app.post("/users", userController.register);
app.listen(3000, () => console.log("listening on http://localhost:3000"));
Output:
listening on http://localhost:3000
Because the service receives its repository by injection, a test can swap in an in-memory adapter and exercise the full business rule without booting Express or Postgres.
// services/user-service.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { UserService, ConflictError } from "./user-service.js";
class FakeRepo {
rows = [];
async findByEmail(email) { return this.rows.find((u) => u.email === email) ?? null; }
async save(user) { this.rows.push(user); return user; }
}
test("rejects a duplicate registration", async () => {
const service = new UserService(new FakeRepo());
await service.register("[email protected]", "Ada");
await assert.rejects(
() => service.register("[email protected]", "Ada"),
ConflictError,
);
});
node --test
Output:
✔ rejects a duplicate registration (1.2ms)
ℹ tests 1
ℹ pass 1
ℹ fail 0
Don’t let domain objects leak framework types. If your service returns a
pgresult or your repository accepts an Expressreq, the layers have fused and the architecture’s benefits evaporate. Pass plain objects across boundaries.
Layer-first vs. feature-first
For a handful of entities, the layer-first layout above reads well. As an app grows to dozens of features, many teams flip to a feature-first (vertical slice) structure—features/users/{controller,service,repository}.js—keeping the same three roles but colocating everything for one feature. Both honor the dependency rule; the difference is only how folders are organized.
Best Practices
- Keep the dependency arrow pointing inward: domain and service code must never import a web framework or a database driver.
- Make each layer talk only to the layer directly beneath it through an interface, never reaching two layers down.
- Throw domain-specific errors (
ConflictError,ValidationError) in the service and translate them to HTTP status codes only in the controller. - Assemble all concrete classes in a single composition root so the rest of the codebase depends on abstractions, not implementations.
- Pass plain domain objects across boundaries—no
req/res, no driver rows—so layers stay swappable and testable. - Write the service tests against an in-memory adapter; reserve database-backed tests for the repository layer itself.
- Start simple: three layers cover most apps. Add more rings (use-case objects, gateways) only when real complexity demands them.