As an application grows, mixing HTTP handling, business rules and database code in one place makes every change risky. A layered structure keeps responsibilities clear: routes/controllers (HTTP in and out), services (business rules), repositories (data access).
Each layer depends on interfaces, not concrete implementations. The service receives its repository through its constructor — this is dependency injection. In production you pass a Postgres repository; in tests you pass an in-memory one.
Keep a single composition root (usually main.ts) where everything is created and wired together. Nothing else should construct its own dependencies.
// ── Domain ───────────────────────────────────────────────
interface Student {
id: string;
name: string;
scores: number[];
}
// ── Repository (data access) ─────────────────────────────
interface StudentRepository {
findById(id: string): Promise<Student | undefined>;
save(student: Student): Promise<void>;
}
class InMemoryStudentRepository implements StudentRepository {
private store = new Map<string, Student>();
async findById(id: string) {
return this.store.get(id);
}
async save(student: Student) {
this.store.set(student.id, student);
}
}
// ── Service (business rules) ─────────────────────────────
class StudentNotFoundError extends Error {}
class ReportService {
constructor(private readonly students: StudentRepository) {}
async addScore(id: string, score: number): Promise<void> {
if (score < 0 || score > 100) throw new RangeError("Score must be 0–100");
const student = await this.students.findById(id);
if (!student) throw new StudentNotFoundError(id);
await this.students.save({ ...student, scores: [...student.scores, score] });
}
async average(id: string): Promise<number> {
const student = await this.students.findById(id);
if (!student) throw new StudentNotFoundError(id);
if (student.scores.length === 0) return 0;
return student.scores.reduce((a, b) => a + b, 0) / student.scores.length;
}
}
// ── Composition root ─────────────────────────────────────
async function main(): Promise<void> {
const repo = new InMemoryStudentRepository(); // swap for a Postgres repository in production
const reports = new ReportService(repo);
await repo.save({ id: "s1", name: "Amina", scores: [] });
await reports.addScore("s1", 80);
await reports.addScore("s1", 90);
console.log("Average:", await reports.average("s1")); // 85
}
main();Key points
- Routes handle HTTP, services hold rules, repositories hold data access.
- Inject dependencies through constructors; depend on interfaces.
- Wire everything in one composition root.
Exercise
Add an HTTP layer on top of ReportService (using the server from the Intermediate track) and map StudentNotFoundError → 404 and RangeError → 400. Write tests for ReportService using the in-memory repository.
Show solution
Try the exercise yourself first — then compare your approach with this one.
The HTTP layer translates domain errors into status codes in one place: StudentNotFoundError → 404, RangeError → 400, anything else → 500. Because ReportService only depends on the StudentRepository interface, the tests use the in-memory repository — no database or server needed.
export interface Student {
id: string;
name: string;
scores: number[];
}
export interface StudentRepository {
findById(id: string): Promise<Student | undefined>;
save(student: Student): Promise<void>;
}
export class InMemoryStudentRepository implements StudentRepository {
private store = new Map<string, Student>();
async findById(id: string) {
return this.store.get(id);
}
async save(student: Student) {
this.store.set(student.id, student);
}
}
export class StudentNotFoundError extends Error {}
export class ReportService {
constructor(private readonly students: StudentRepository) {}
async addScore(id: string, score: number): Promise<void> {
if (score < 0 || score > 100) throw new RangeError("Score must be 0-100");
const student = await this.students.findById(id);
if (!student) throw new StudentNotFoundError(id);
await this.students.save({ ...student, scores: [...student.scores, score] });
}
async average(id: string): Promise<number> {
const student = await this.students.findById(id);
if (!student) throw new StudentNotFoundError(id);
return student.scores.length ? student.scores.reduce((a, b) => a + b, 0) / student.scores.length : 0;
}
}
/** HTTP layer: one place that maps domain errors to status codes. */
export function statusFor(err: unknown): number {
if (err instanceof StudentNotFoundError) return 404;
if (err instanceof RangeError) return 400;
return 500;
}import { beforeEach, describe, it } from "node:test";
import assert from "node:assert/strict";
import { InMemoryStudentRepository, ReportService, StudentNotFoundError, statusFor } from "./report-service";
describe("ReportService", () => {
let repo: InMemoryStudentRepository;
let service: ReportService;
beforeEach(async () => {
repo = new InMemoryStudentRepository();
service = new ReportService(repo);
await repo.save({ id: "s1", name: "Amina", scores: [] });
});
it("averages added scores", async () => {
await service.addScore("s1", 80);
await service.addScore("s1", 90);
assert.equal(await service.average("s1"), 85);
});
it("returns 0 for a student with no scores", async () => {
assert.equal(await service.average("s1"), 0);
});
it("rejects out-of-range scores and unknown students", async () => {
await assert.rejects(service.addScore("s1", 101), RangeError);
await assert.rejects(service.average("nope"), StudentNotFoundError);
});
it("maps errors to HTTP status codes", () => {
assert.equal(statusFor(new StudentNotFoundError("x")), 404);
assert.equal(statusFor(new RangeError("bad")), 400);
assert.equal(statusFor(new Error("boom")), 500);
});
});