AdvancedTypeScript · Lesson 6 of 10

Application Architecture & Dependency Injection

Separate routes, services and data access so code stays testable as it grows.

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.

architecture.tsTypeScript
// ── 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.

report-service.tsTypeScript
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;
}
report-service.test.tsTypeScript
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);
  });
});

Check your understanding

  1. In a layered app, where do business rules such as "a score must be 0–100" belong?

  2. What is dependency injection in this lesson?

  3. Why does the service depend on the StudentRepository interface rather than a Postgres class?

  4. What is the composition root?

Ask AI