How to buildOctober 11, 202610 min read
Spring BootStep 4 of 7

Backend application layers: controller, service, repository

In short

Backend code is split into three layers: the controller turns an HTTP request into a call and the result back into an HTTP response, the service applies business rules and controls transactions, and the repository reads and writes data. Input shape validation lives in the controller, domain rules in the service, and SQL only in the repository. This split lets you test rules without HTTP or a database by passing an in-memory repository, and change storage without touching the logic.

FundamentalsLevel: IntermediateBackend Architecture

After this you will be able to

  • Split a fat Express handler into a controller, a service and a repository while keeping the HTTP contract
  • Explain which checks belong to the controller, which to the service and which to database constraints
  • Move a business rule from the controller into the service and cover it with a test that swaps the repository
  • Wrap several writes in one service-level transaction and explain why uniqueness is guaranteed by the database, not by a check in code

Verified with

Node.js 24 LTS (24.18)TypeScript 7.0.2 / 6.0.3 / 5.9.3 (tsc --strict)Express 5.3.0Zod 4.6pg (node-postgres) 8.23PostgreSQL 17.11 (Docker, postgres:17-alpine)Vitest 5.0, supertest 7.3, tsx 4.23NestJS 12.1 (фрагмент сравнения)

Checked against docs: October 11, 2026

Contents
  1. 01The symptom: a fat controller
  2. 02Three layers and who does what
  3. 03Controller: HTTP and nothing else
  4. 04Service: business rules and transactions
  5. 05Repository: data access
  6. 06Verification: a service test with a swapped repository
  7. 07How this looks in NestJS and Spring, and when there are too many layers
  8. 08Exercises

The symptom: a fat controller

Start with code almost everyone writes — and that works fine while the project is small. A leads API on Express: create a lead and change its status. Everything sits right inside the route handlers.

src/before/app.ts
import { randomUUID } from "node:crypto";
import express from "express";

type Lead = {
  id: string;
  name: string;
  phone: string;
  status: "new" | "in_progress" | "done";
  createdAt: string;
};

const leads = new Map<string, Lead>();

export const app = express();
app.use(express.json());

app.post("/leads", (req, res) => {
  const { name, phone } = req.body ?? {};
  if (typeof name !== "string" || name.trim() === "") {
    res.status(400).json({ error: "name is required" });
    return;
  }
  if (typeof phone !== "string" || !/^\+7\d{10}$/.test(phone)) {
    res.status(400).json({ error: "phone must be +7XXXXXXXXXX" });
    return;
  }
  for (const lead of leads.values()) {
    if (lead.phone === phone && lead.status !== "done") {
      res.status(409).json({ error: "duplicate lead" });
      return;
    }
  }
  const lead: Lead = {
    id: randomUUID(),
    name: name.trim(),
    phone,
    status: "new",
    createdAt: new Date().toISOString(),
  };
  leads.set(lead.id, lead);
  res.status(201).json(lead);
});

app.patch("/leads/:id/status", (req, res) => {
  const lead = leads.get(req.params.id);
  if (!lead) {
    res.status(404).json({ error: "not found" });
    return;
  }
  const next = req.body?.status;
  if (lead.status === "done" || !["in_progress", "done"].includes(next)) {
    res.status(409).json({ error: "transition not allowed" });
    return;
  }
  lead.status = next;
  res.json(lead);
});
Before: validation, rules, storage and HTTP in one place. The code works — the same contract test checks it and the refactored version

Each handler mixes four different jobs: HTTP parsing (body, params, status codes), input shape validation (name not empty, phone format), domain rules (one open lead per phone, no way back from done) and storage (Map). With two handlers nobody notices. Problems start with change:

  • Leads start arriving from a Telegram bot and a CSV import. The one-open-lead-per-phone rule is needed there too, but it is buried in an HTTP handler — it gets copied, and the copies drift apart.
  • To test the status transition rule you have to boot HTTP and send requests: there is no separate function a test can call.
  • Moving from Map to PostgreSQL touches every handler, because data access is spread across the code.
  • Changing the status and writing to the change history must happen together. A handler has no place to declare that these two writes are one operation.

Three layers and who does what

  1. 01ControllerHTTP: route, parsing and shape validation, status codes, response DTO
  2. 02ServiceUse cases and business rules, transaction boundaries, domain errors
  3. 03RepositoryReading and writing data, SQL, mapping table rows to objects
  4. 04DatabaseStorage and integrity constraints: NOT NULL, CHECK, unique indexes
Dependencies point downwards: the controller knows the service, the service knows the repository interface, never the other way round

In his catalog of enterprise application patterns, Martin Fowler describes the Service Layer as the application boundary: a set of available operations, each coordinating the application's response. His Repository mediates between the domain and the data mapping layers and acts like an in-memory collection of objects. These two patterns give us the service and the repository; the controller is an adapter between HTTP and the service.

Is phone present and does it look like a phone?

Controller
Yes — Zod schema
Service
—
Repository / DB
—

An extra role field in the body?

Controller
Yes — reject with 400
Service
—
Repository / DB
—

Does this phone already have an open lead?

Controller
—
Service
Yes — a rule with a clear error
Repository / DB
Unique index — the guarantee under a race

Can the status go from done to new?

Controller
—
Service
Yes — the transition table
Repository / DB
—

Status and history must be written together

Controller
—
Service
Yes — the transaction boundary
Repository / DB
BEGIN / COMMIT / ROLLBACK

What is the column called?

Controller
—
Service
—
Repository / DB
Yes — created_at → createdAt

Which HTTP code means «not found»?

Controller
Yes — the error handler
Service
Throws LeadNotFoundError
Repository / DB
Returns null

The key distinction is between shape validation and a business rule. Shape answers «can this request be understood at all»: types, required fields, length, format. The answer does not depend on system state, so the check lives at the entry point, in the controller. A business rule answers «is this operation allowed right now»: answering it requires reading data, so it belongs to the service. Database constraints are the last line: they hold even when two requests check the rule at the same moment.

Data also changes shape between layers. The input DTO is what the client sent, after schema validation. The domain Lead is what rules work with (the date is a Date). A table row is what SQL returns (created_at). The response DTO is what the API promises clients (the date is an ISO string, the phone is not returned). Mapping at the boundaries lets you rename a column or hide a field without breaking the other layers.

Controller: HTTP and nothing else

bash
npm init -y && npm pkg set type=module
npm install express@5 zod@4 pg
npm install -D typescript @types/node@24 @types/express @types/pg vitest supertest @types/supertest tsx
docker run -d --rm --name leads-pg -e POSTGRES_PASSWORD=test -p 127.0.0.1:55432:5432 postgres:17-alpine
The lesson project: dependencies and PostgreSQL 17 in Docker
tsconfig.json
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}
Strict mode and ESM module resolution: relative imports carry .js

First the model and the domain errors. They know nothing about HTTP or the database, so every layer can import them.

src/leads/lead.ts
export const LEAD_STATUSES = ["new", "in_progress", "done"] as const;
export type LeadStatus = (typeof LEAD_STATUSES)[number];

export type Lead = {
  id: string;
  name: string;
  phone: string;
  message: string | null;
  status: LeadStatus;
  createdAt: Date;
};

export type LeadEvent = {
  leadId: string;
  from: LeadStatus | null;
  to: LeadStatus;
  at: Date;
};

const TRANSITIONS: Record<LeadStatus, readonly LeadStatus[]> = {
  new: ["in_progress", "done"],
  in_progress: ["done"],
  done: [],
};

export function canTransition(from: LeadStatus, to: LeadStatus): boolean {
  return TRANSITIONS[from].includes(to);
}

export class DomainError extends Error {
  constructor(
    readonly code: string,
    message: string,
  ) {
    super(message);
  }
}

export class LeadNotFoundError extends DomainError {
  constructor(id: string) {
    super("LEAD_NOT_FOUND", `Lead ${id} not found`);
  }
}

export class DuplicateLeadError extends DomainError {
  constructor() {
    super("LEAD_DUPLICATE", "An open lead with this phone already exists");
  }
}

export class InvalidTransitionError extends DomainError {
  constructor(from: LeadStatus, to: LeadStatus) {
    super("LEAD_INVALID_TRANSITION", `Cannot change status from ${from} to ${to}`);
  }
}
The lead model, the status transition table and domain errors with a machine-readable code

The controller does three things: validates the request shape with a Zod schema, calls the service and turns the result into a response DTO. z.strictObject rejects unknown fields, and z.flattenError turns errors into a field → messages object. Handlers are async: in Express 5 a rejected promise from a handler is passed to the error handler automatically, so no try/catch is needed in each route.

src/leads/leads.controller.ts
import { Router } from "express";
import { z } from "zod";
import { type Lead, LEAD_STATUSES, LeadNotFoundError } from "./lead.js";
import type { LeadsService } from "./leads.service.js";

const createLeadSchema = z.strictObject({
  name: z.string().trim().min(1).max(100),
  phone: z.string().regex(/^\+7\d{10}$/, "phone must be +7XXXXXXXXXX"),
  message: z.string().max(1000).optional(),
});

const changeStatusSchema = z.strictObject({
  status: z.enum(LEAD_STATUSES),
});

const idSchema = z.uuid();

// A malformed id cannot exist in storage: answer 404 without a database query
function parseId(raw: string): string {
  if (!idSchema.safeParse(raw).success) throw new LeadNotFoundError(raw);
  return raw;
}

// Response DTO: what the API promises to clients, independent of storage
const toResponse = (lead: Lead) => ({
  id: lead.id,
  name: lead.name,
  status: lead.status,
  createdAt: lead.createdAt.toISOString(),
});

export function leadsRouter(service: LeadsService): Router {
  const router = Router();

  router.post("/", async (req, res) => {
    const parsed = createLeadSchema.safeParse(req.body);
    if (!parsed.success) {
      res.status(400).json(z.flattenError(parsed.error));
      return;
    }
    const lead = await service.create(parsed.data);
    res.status(201).json(toResponse(lead));
  });

  router.get("/:id", async (req, res) => {
    res.json(toResponse(await service.get(parseId(req.params.id))));
  });

  router.patch("/:id/status", async (req, res) => {
    const parsed = changeStatusSchema.safeParse(req.body);
    if (!parsed.success) {
      res.status(400).json(z.flattenError(parsed.error));
      return;
    }
    const lead = await service.changeStatus(parseId(req.params.id), parsed.data.status);
    res.json(toResponse(lead));
  });

  return router;
}
The controller: schema in, service call, DTO out. Not a single rule and not a single SQL query

Mapping domain errors to HTTP codes happens in one place — an error-handling middleware with four arguments. The service does not know that «not found» means 404: the same error in a Telegram bot becomes a message text. Unknown errors are logged, and the client gets a 500 without details. The error body format is simplified here; a consistent response format is covered in the Express REST API lesson.

src/http/error-handler.ts
import type { ErrorRequestHandler } from "express";
import {
  DomainError,
  DuplicateLeadError,
  InvalidTransitionError,
  LeadNotFoundError,
} from "../leads/lead.js";

function statusFor(error: DomainError): number {
  if (error instanceof LeadNotFoundError) return 404;
  if (error instanceof DuplicateLeadError) return 409;
  if (error instanceof InvalidTransitionError) return 409;
  return 400;
}

export const errorHandler: ErrorRequestHandler = (err, _req, res, _next) => {
  if (err instanceof DomainError) {
    res.status(statusFor(err)).json({ code: err.code, message: err.message });
    return;
  }
  console.error(err);
  res.status(500).json({ code: "INTERNAL", message: "Internal server error" });
};
The only place where a domain error becomes an HTTP status
src/app.ts
import express from "express";
import { errorHandler } from "./http/error-handler.js";
import { leadsRouter } from "./leads/leads.controller.js";
import type { LeadsService } from "./leads/leads.service.js";

export function createApp(leads: LeadsService) {
  const app = express();
  app.use(express.json());
  app.use("/leads", leadsRouter(leads));
  app.use(errorHandler);
  return app;
}
Building the app: the service comes from outside, so a test can pass any service

Service: business rules and transactions

The service receives its dependencies through the constructor: the repository and a clock function. This is the same dependency injection the NestJS container performs, done by hand. Injecting the clock makes tests deterministic: the event date is always the same.

src/leads/leads.service.ts
import { randomUUID } from "node:crypto";
import {
  canTransition,
  DuplicateLeadError,
  InvalidTransitionError,
  type Lead,
  LeadNotFoundError,
  type LeadStatus,
} from "./lead.js";
import type { LeadsRepository } from "./leads.repository.js";

export type CreateLeadInput = { name: string; phone: string; message?: string };

export class LeadsService {
  constructor(
    private readonly repo: LeadsRepository,
    private readonly now: () => Date = () => new Date(),
  ) {}

  create(input: CreateLeadInput): Promise<Lead> {
    return this.repo.transaction(async (tx) => {
      if (await tx.findOpenByPhone(input.phone)) {
        throw new DuplicateLeadError();
      }
      const lead: Lead = {
        id: randomUUID(),
        name: input.name,
        phone: input.phone,
        message: input.message ?? null,
        status: "new",
        createdAt: this.now(),
      };
      await tx.insert(lead);
      await tx.addEvent({ leadId: lead.id, from: null, to: "new", at: lead.createdAt });
      return lead;
    });
  }

  async get(id: string): Promise<Lead> {
    const lead = await this.repo.findById(id);
    if (!lead) throw new LeadNotFoundError(id);
    return lead;
  }

  changeStatus(id: string, to: LeadStatus): Promise<Lead> {
    return this.repo.transaction(async (tx) => {
      const lead = await tx.findById(id);
      if (!lead) throw new LeadNotFoundError(id);
      if (!canTransition(lead.status, to)) {
        throw new InvalidTransitionError(lead.status, to);
      }
      await tx.updateStatus(id, to);
      await tx.addEvent({ leadId: id, from: lead.status, to, at: this.now() });
      return { ...lead, status: to };
    });
  }
}
The service: rules and transaction boundaries. It works against the repository interface and does not know whether that is a Map or PostgreSQL

Why the transaction starts in the service rather than in the controller or the repository. A transaction is the boundary of a business operation: «create a lead and record an event» is one operation, «change the status and record an event» is another. Only the service knows this. The controller should not know how many writes an operation makes, and a single repository method sees only its own write. Fowler explicitly assigns transaction control to the service layer; Spring works the same way, with @Transactional on service methods.

Inside transaction the service uses the provided tx, not this.repo, so every read and write goes through the same database connection. If any step throws — a rule is violated, the event write fails — everything rolls back, and the database never holds a lead without its history.

Repository: data access

The service depends on an interface, not a concrete class. It contains only what the use cases need, in domain terms: «find an open lead by phone», not «run a SELECT».

src/leads/leads.repository.ts
import type { Lead, LeadEvent, LeadStatus } from "./lead.js";

export interface LeadsRepository {
  findById(id: string): Promise<Lead | null>;
  findOpenByPhone(phone: string): Promise<Lead | null>;
  insert(lead: Lead): Promise<void>;
  updateStatus(id: string, status: LeadStatus): Promise<void>;
  addEvent(event: LeadEvent): Promise<void>;
  /** Runs work atomically: either every write inside it is saved, or none is. */
  transaction<T>(work: (tx: LeadsRepository) => Promise<T>): Promise<T>;
}
The storage contract — implemented by both the Map and PostgreSQL versions

The first implementation is in memory. It serves tests and running without a database. Its «transaction» is a snapshot restored on error; a teaching stand-in that does not protect against concurrent requests.

src/leads/in-memory-leads.repository.ts
import type { Lead, LeadEvent, LeadStatus } from "./lead.js";
import type { LeadsRepository } from "./leads.repository.js";

export class InMemoryLeadsRepository implements LeadsRepository {
  leads = new Map<string, Lead>();
  events: LeadEvent[] = [];

  async findById(id: string): Promise<Lead | null> {
    const lead = this.leads.get(id);
    return lead ? { ...lead } : null;
  }

  async findOpenByPhone(phone: string): Promise<Lead | null> {
    for (const lead of this.leads.values()) {
      if (lead.phone === phone && lead.status !== "done") return { ...lead };
    }
    return null;
  }

  async insert(lead: Lead): Promise<void> {
    this.leads.set(lead.id, { ...lead });
  }

  async updateStatus(id: string, status: LeadStatus): Promise<void> {
    const lead = this.leads.get(id);
    if (lead) lead.status = status;
  }

  async addEvent(event: LeadEvent): Promise<void> {
    this.events.push(event);
  }

  // Teaching stand-in for a real transaction: restores a snapshot on error.
  // Not safe for concurrent requests — that is the database's job.
  async transaction<T>(work: (tx: LeadsRepository) => Promise<T>): Promise<T> {
    const leads = new Map([...this.leads].map(([id, lead]) => [id, { ...lead }]));
    const events = [...this.events];
    try {
      return await work(this);
    } catch (error) {
      this.leads = leads;
      this.events = events;
      throw error;
    }
  }
}
In-memory repository: the fake for service tests

The second is PostgreSQL via node-postgres. The library docs require a transaction to run on a single client from the pool (pool.connect()), not via pool.query, and the client to be released in finally. The transaction method builds a repository on top of that client and hands it to the service's work. The toLead mapper is the only place that knows column names.

src/leads/pg-leads.repository.ts
import pg from "pg";
import { DuplicateLeadError, type Lead, type LeadEvent, type LeadStatus } from "./lead.js";
import type { LeadsRepository } from "./leads.repository.js";

type LeadRow = {
  id: string;
  name: string;
  phone: string;
  message: string | null;
  status: LeadStatus;
  created_at: Date;
};

const toLead = (row: LeadRow): Lead => ({
  id: row.id,
  name: row.name,
  phone: row.phone,
  message: row.message,
  status: row.status,
  createdAt: row.created_at,
});

export class PgLeadsRepository implements LeadsRepository {
  constructor(private readonly db: pg.Pool | pg.PoolClient) {}

  async findById(id: string): Promise<Lead | null> {
    const { rows } = await this.db.query<LeadRow>("SELECT * FROM leads WHERE id = $1", [id]);
    return rows[0] ? toLead(rows[0]) : null;
  }

  async findOpenByPhone(phone: string): Promise<Lead | null> {
    const { rows } = await this.db.query<LeadRow>(
      "SELECT * FROM leads WHERE phone = $1 AND status <> 'done'",
      [phone],
    );
    return rows[0] ? toLead(rows[0]) : null;
  }

  async insert(lead: Lead): Promise<void> {
    try {
      await this.db.query(
        `INSERT INTO leads (id, name, phone, message, status, created_at)
         VALUES ($1, $2, $3, $4, $5, $6)`,
        [lead.id, lead.name, lead.phone, lead.message, lead.status, lead.createdAt],
      );
    } catch (error) {
      // 23505 = unique_violation: another request created an open lead first
      if (error instanceof Error && "code" in error && error.code === "23505") {
        throw new DuplicateLeadError();
      }
      throw error;
    }
  }

  async updateStatus(id: string, status: LeadStatus): Promise<void> {
    await this.db.query("UPDATE leads SET status = $2 WHERE id = $1", [id, status]);
  }

  async addEvent(event: LeadEvent): Promise<void> {
    await this.db.query(
      "INSERT INTO lead_events (lead_id, from_status, to_status, at) VALUES ($1, $2, $3, $4)",
      [event.leadId, event.from, event.to, event.at],
    );
  }

  async transaction<T>(work: (tx: LeadsRepository) => Promise<T>): Promise<T> {
    if (!(this.db instanceof pg.Pool)) return work(this); // already inside a transaction
    const client = await this.db.connect();
    try {
      await client.query("BEGIN");
      const result = await work(new PgLeadsRepository(client));
      await client.query("COMMIT");
      return result;
    } catch (error) {
      await client.query("ROLLBACK");
      throw error;
    } finally {
      client.release();
    }
  }
}
PostgreSQL repository: SQL, row mapping, a transaction on one client, error 23505 → domain error
schema.sql
CREATE TABLE leads (
  id uuid PRIMARY KEY,
  name text NOT NULL,
  phone text NOT NULL,
  message text,
  status text NOT NULL CHECK (status IN ('new', 'in_progress', 'done')),
  created_at timestamptz NOT NULL
);

-- one open lead per phone, enforced by the database
CREATE UNIQUE INDEX leads_open_phone_uq ON leads (phone) WHERE status <> 'done';

CREATE TABLE lead_events (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  lead_id uuid NOT NULL REFERENCES leads (id),
  from_status text,
  to_status text NOT NULL,
  at timestamptz NOT NULL
);
Schema: a partial unique index allows one open lead per phone

The partial unique index WHERE status <> 'done' is the guarantee. The second of two concurrent requests waits for the first to commit, gets a unique violation with code 23505, and the repository turns it into the same DuplicateLeadError. The client sees a 409 either way — whether the rule fired in the service or the database caught it.

TypeScript
it("lets only one of five concurrent requests create an open lead", async () => {
  // open five connections up front so the requests really overlap
  const clients = await Promise.all(Array.from({ length: 5 }, () => pool.connect()));
  clients.forEach((client) => client.release());

  const input = { name: "Aida", phone: "+77011234567" };
  const results = await Promise.allSettled(
    Array.from({ length: 5 }, () => service.create(input)),
  );
  const rejected = results.filter((r) => r.status === "rejected");
  expect(results.length - rejected.length).toBe(1);
  for (const r of rejected) expect(r.reason).toBeInstanceOf(DuplicateLeadError);
});
Excerpt from test/pg.integration.test.ts: five simultaneous requests for one phone
terminal
$ npx vitest run test/pg.integration.test.ts   # schema.sql without the leads_open_phone_uq index

 ❯ test/pg.integration.test.ts (3 tests | 1 failed) 103ms
     × lets only one of five concurrent requests create an open lead 29ms

AssertionError: expected 5 to be 1 // Object.is equality

- Expected
+ Received

- 1
+ 5
Real output with the index removed from the schema: five duplicates were created. With the index the test passes

The last piece is the composition root: the only place that knows which repository is used. The rest of the code does not depend on that choice.

src/server.ts
import pg from "pg";
import { createApp } from "./app.js";
import { InMemoryLeadsRepository } from "./leads/in-memory-leads.repository.js";
import type { LeadsRepository } from "./leads/leads.repository.js";
import { LeadsService } from "./leads/leads.service.js";
import { PgLeadsRepository } from "./leads/pg-leads.repository.js";

// Composition root: the only place that knows which storage is used
const repo: LeadsRepository = process.env.DATABASE_URL
  ? new PgLeadsRepository(new pg.Pool({ connectionString: process.env.DATABASE_URL }))
  : new InMemoryLeadsRepository();

const app = createApp(new LeadsService(repo));
const port = Number(process.env.PORT ?? 3000);
app.listen(port, () => console.log(`Listening on http://localhost:${port}`));
Run: npx tsx src/server.ts — PostgreSQL with DATABASE_URL, in-memory storage without it
terminal
$ curl -i -X POST localhost:3000/leads -H 'Content-Type: application/json' \
    -d '{"name":"Aida","phone":"+77011234567"}'
HTTP/1.1 201 Created
{"id":"6a5c7758-43da-4539-bb6f-05b392aa36bf","name":"Aida","status":"new","createdAt":"2026-10-11T23:25:50.185Z"}

$ curl -i -X POST localhost:3000/leads -H 'Content-Type: application/json' \
    -d '{"name":"Aida","phone":"+77011234567"}'
HTTP/1.1 409 Conflict
{"code":"LEAD_DUPLICATE","message":"An open lead with this phone already exists"}

$ curl -i -X PATCH localhost:3000/leads/6a5c7758-43da-4539-bb6f-05b392aa36bf/status \
    -H 'Content-Type: application/json' -d '{"status":"done"}'
HTTP/1.1 200 OK
{"id":"6a5c7758-43da-4539-bb6f-05b392aa36bf","name":"Aida","status":"done","createdAt":"2026-10-11T23:25:50.185Z"}

$ curl -i -X PATCH localhost:3000/leads/6a5c7758-43da-4539-bb6f-05b392aa36bf/status \
    -H 'Content-Type: application/json' -d '{"status":"new"}'
HTTP/1.1 409 Conflict
{"code":"LEAD_INVALID_TRANSITION","message":"Cannot change status from done to new"}

$ docker exec leads-pg psql -U postgres -c "SELECT from_status, to_status FROM lead_events ORDER BY id"
 from_status | to_status
-------------+-----------
             | new
 new         | done
(2 rows)
Real responses from the app on PostgreSQL and the history written in the same transactions

Verification: a service test with a swapped repository

Rules are now tested without HTTP and without a database: the service gets an in-memory repository and a fixed clock. The rollback test swaps in a subclass whose event write always fails and checks that the lead was not saved either.

src/leads/leads.service.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { InMemoryLeadsRepository } from "./in-memory-leads.repository.js";
import { DuplicateLeadError, InvalidTransitionError, type LeadEvent } from "./lead.js";
import { LeadsService } from "./leads.service.js";

const NOW = new Date("2026-10-11T09:00:00Z");

describe("LeadsService", () => {
  let repo: InMemoryLeadsRepository;
  let service: LeadsService;

  beforeEach(() => {
    repo = new InMemoryLeadsRepository();
    service = new LeadsService(repo, () => NOW);
  });

  it("creates a lead and records a 'created' event", async () => {
    const lead = await service.create({ name: "Aida", phone: "+77011234567" });
    expect(lead).toMatchObject({ status: "new", message: null, createdAt: NOW });
    expect(repo.events).toEqual([{ leadId: lead.id, from: null, to: "new", at: NOW }]);
  });

  it("rejects a second open lead with the same phone", async () => {
    await service.create({ name: "Aida", phone: "+77011234567" });
    await expect(service.create({ name: "Aida", phone: "+77011234567" })).rejects.toThrow(
      DuplicateLeadError,
    );
  });

  it("allows new → in_progress → done and forbids leaving done", async () => {
    const lead = await service.create({ name: "Aida", phone: "+77011234567" });
    await service.changeStatus(lead.id, "in_progress");
    await service.changeStatus(lead.id, "done");
    await expect(service.changeStatus(lead.id, "new")).rejects.toThrow(InvalidTransitionError);
    expect(repo.events.map((e) => e.to)).toEqual(["new", "in_progress", "done"]);
  });

  it("rolls back the lead when writing the event fails", async () => {
    class FailingEvents extends InMemoryLeadsRepository {
      override async addEvent(_event: LeadEvent): Promise<void> {
        throw new Error("event store is down");
      }
    }
    const failing = new FailingEvents();
    const svc = new LeadsService(failing, () => NOW);

    await expect(svc.create({ name: "Aida", phone: "+77011234567" })).rejects.toThrow(
      "event store is down",
    );
    expect(failing.leads.size).toBe(0);
  });
});
Service unit tests: rules, transitions and transaction rollback

A refactoring must not change API behaviour. The contract test runs the same scenario against the old app and the new one: status codes must match. It is insurance during the move — once the old code is deleted, the test stays as a check of the HTTP layer.

test/contract.test.ts
import type { Express } from "express";
import request from "supertest";
import { describe, expect, it } from "vitest";
import { createApp } from "../src/app.js";
import { app as before } from "../src/before/app.js";
import { InMemoryLeadsRepository } from "../src/leads/in-memory-leads.repository.js";
import { LeadsService } from "../src/leads/leads.service.js";

const after = createApp(new LeadsService(new InMemoryLeadsRepository()));

// The same HTTP contract must hold before and after the refactoring
describe.each([
  ["before: fat handlers", before as Express],
  ["after: three layers", after],
])("%s", (_name, app) => {
  it("keeps status codes for the main scenarios", async () => {
    const phone = "+77019998877";
    const created = await request(app).post("/leads").send({ name: "Aida", phone }).expect(201);
    expect(created.body).toMatchObject({ name: "Aida", status: "new" });

    await request(app).post("/leads").send({ name: "", phone }).expect(400);
    await request(app).post("/leads").send({ name: "Aida", phone }).expect(409);

    const id = created.body.id as string;
    await request(app).patch(`/leads/${id}/status`).send({ status: "done" }).expect(200);
    await request(app).patch(`/leads/${id}/status`).send({ status: "new" }).expect(409);
    await request(app).patch("/leads/missing/status").send({ status: "done" }).expect(404);
  });
});

describe("after: error bodies", () => {
  it("returns field errors and domain error codes", async () => {
    const bad = await request(after)
      .post("/leads")
      .send({ name: "", phone: "8701", role: "admin" })
      .expect(400);
    expect(bad.body).toEqual({
      formErrors: ['Unrecognized key: "role"'],
      fieldErrors: {
        name: ["Too small: expected string to have >=1 characters"],
        phone: ["phone must be +7XXXXXXXXXX"],
      },
    });

    const missing = await request(after).get("/leads/missing").expect(404);
    expect(missing.body).toEqual({ code: "LEAD_NOT_FOUND", message: "Lead missing not found" });
  });
});
Contract test: before and after answer with the same status codes
terminal
$ DATABASE_URL=postgres://postgres:test@localhost:55432/postgres npx vitest run --reporter=verbose

 RUN  v5.0.3 …/leads-layers

 ✓ src/leads/leads.service.test.ts > LeadsService > creates a lead and records a 'created' event 2ms
 ✓ src/leads/leads.service.test.ts > LeadsService > rejects a second open lead with the same phone 1ms
 ✓ src/leads/leads.service.test.ts > LeadsService > allows new → in_progress → done and forbids leaving done 0ms
 ✓ src/leads/leads.service.test.ts > LeadsService > rolls back the lead when writing the event fails 1ms
 ✓ test/pg.integration.test.ts > PgLeadsRepository + LeadsService (PostgreSQL) > writes the lead and its event in one transaction 25ms
 ✓ test/pg.integration.test.ts > PgLeadsRepository + LeadsService (PostgreSQL) > rolls back every write when the work throws 9ms
 ✓ test/pg.integration.test.ts > PgLeadsRepository + LeadsService (PostgreSQL) > lets only one of five concurrent requests create an open lead 65ms
 ✓ test/contract.test.ts > before: fat handlers > keeps status codes for the main scenarios 42ms
 ✓ test/contract.test.ts > after: three layers > keeps status codes for the main scenarios 14ms
 ✓ test/contract.test.ts > after: error bodies > returns field errors and domain error codes 4ms

 Test Files  3 passed (3)
      Tests  10 passed (10)
Real output: 10 tests, including 3 integration tests on PostgreSQL 17. Without DATABASE_URL the integration tests are skipped

This test layout follows from the layers: many fast service tests with a fake, a few HTTP tests of the controller and a few repository integration tests against a real database. Test databases, fixtures and data cleanup are covered in detail in the backend testing lesson.

How this looks in NestJS and Spring, and when there are too many layers

The layers are the same in all popular backend frameworks; what differs is who wires them together.

Controller

Express (this lesson)
Router + Zod schema
NestJS
@Controller + DTO and ValidationPipe
Spring Boot
@RestController + Bean Validation (@Valid)

Service

Express (this lesson)
A class, constructor dependencies wired by hand
NestJS
An @Injectable() provider injected by the container
Spring Boot
@Service, injected by the Spring container

Transaction

Express (this lesson)
The repository transaction method
NestJS
Nothing built in — via an ORM or your own wrapper
Spring Boot
@Transactional on a service method

Repository

Express (this lesson)
Interface + classes for Map and PostgreSQL
NestJS
A provider by token: @Inject(LEADS_REPOSITORY)
Spring Boot
A CrudRepository/JpaRepository interface from Spring Data

Wiring

Express (this lesson)
The server.ts composition root
NestJS
Module providers
Spring Boot
Auto-configuration and component scanning

In NestJS an interface cannot be an injection token — TypeScript interfaces are erased at compile time. So the repository is registered under a symbol and injected with @Inject. In a test it is replaced via overrideProvider(LEADS_REPOSITORY).

TypeScript
import { Inject, Injectable, Module } from '@nestjs/common';

export const LEADS_REPOSITORY = Symbol('LEADS_REPOSITORY');

@Injectable()
export class LeadsService {
  constructor(
    @Inject(LEADS_REPOSITORY) private readonly repo: LeadsRepository,
  ) {}

  async isDuplicate(phone: string): Promise<boolean> {
    return (await this.repo.findOpenByPhone(phone)) !== null;
  }
}

@Module({
  providers: [
    LeadsService,
    { provide: LEADS_REPOSITORY, useClass: InMemoryLeadsRepository },
  ],
})
export class LeadsModule {}
NestJS 12 excerpt (token, service and module; not a standalone app): checked with tsc and two tests, including a repository override

In Spring, the docs recommend putting @Transactional on methods of concrete classes; by default RuntimeException and Error trigger a rollback, checked exceptions do not. More in the Spring Boot REST API lesson.

Layers pay off

  • There are rules worth testing: status transitions, limits, calculations
  • The same use case is called from HTTP, a queue, a bot or cron
  • An operation writes to several tables and needs a transaction
  • Storage may change, or there are already two implementations

Too many layers

  • The service just forwards to the repository without a single rule — fine for a CRUD lookup table; call the repository directly
  • Every entity gets an interface, an implementation, a factory and a mapper although there is one implementation that will not change
  • One new field means editing five files
  • Layers for their own sake: «that's how it's done» instead of «here is what it gives us»

In our experience a good rule of thumb is to start with a controller and a service and introduce the repository as a separate interface when a second implementation or a database-free service test appears. The next step after layers is clean architecture, where the dependency rule is taken all the way and the core does not even know about the framework.

Exercises

Next: the NestJS lesson shows the same controller and service with framework-provided dependency injection, the backend testing lesson covers integration tests with a database in depth, and the clean architecture lesson shows how to separate the application core from the framework completely.

How this was verified6 · passedchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.