Create your own
Lesson illustration

Designing Layered Module Boundaries in TypeScript

Good to see you again. In the previous lesson, you created a typed configuration boundary: raw deployment input enters through process.env, is validated once, and becomes an explicit AppConfig dependency. The same idea now applies to the rest of the service: HTTP requests, databases, and Node libraries are all external details that should enter through deliberate boundaries rather than leak across the codebase.

This lesson designs those boundaries for the project-management capstone. You will separate transport code that speaks HTTP, application code that performs a user-facing operation, and infrastructure code that talks to storage or platform APIs. The goal is not to create a large ceremony-heavy architecture. It is to make one feature understandable, testable, and replaceable in the places where replacement actually matters. Plan for about 40 minutes.


1. A module boundary is more useful than a folder named services

Consider a first implementation of an endpoint to create a project:

app.post("/projects", async (req, res) => {
  const ownerId = req.user.id;
  const name = req.body.name.trim();

  const duplicate = await pool.query(
    `SELECT id FROM projects WHERE owner_id = $1 AND name = $2`,
    [ownerId, name],
  );

  if (duplicate.rowCount > 0) {
    return res.status(409).json({
      error: "Project name is already in use",
    });
  }

  const result = await pool.query(
    `INSERT INTO projects (owner_id, name)
     VALUES ($1, $2)
     RETURNING id, name`,
    [ownerId, name],
  );

  return res.status(201).json(result.rows[0]);
});

This is a normal starting point, and it works for a small endpoint. But it combines four distinct decisions in one place:

  • HTTP handling: extracting data from req, choosing status codes, writing JSON.
  • Application policy: a user cannot create two projects with the same name.
  • Persistence mechanics: SQL syntax, connection-pool calls, database row shapes.
  • Framework and middleware assumptions: the exact shape of Express’s request and the authentication setup.

The problem is not that any one line is wrong. The problem is that a change in one concern forces you to open code responsible for the others. Testing the duplicate-name rule now requires an HTTP setup and a database test double. Moving from Express to another Node framework means touching logic that has nothing to do with HTTP. Replacing an in-memory repository with PostgreSQL later risks modifying the feature’s core behavior.

A useful boundary gives each area a narrow job:

AreaPrimary question it answersShould know about
TransportHow does this protocol request become an application call, and how is a result returned?HTTP routes, headers, request and response objects, status codes
ApplicationWhat operation does the system perform, in what order, and under which rules?Use-case input/output, domain types, interfaces it needs
InfrastructureHow is a needed capability actually performed?PostgreSQL, a query client, filesystem, email SDKs, Node APIs
Composition rootWhich concrete implementations are used in this running process?All layers, configuration, startup

For the capstone, “transport” currently means an HTTP API. Later, the same application operation could be called by a CLI command, a background job, or a queue consumer without making the use case depend on HTTP.


2. The dependency rule: the core does not import its delivery mechanisms

The central architectural rule is about source-code imports, not the order in which code runs:

Code that expresses application policy must not import code that implements external details.

An HTTP request will enter through transport. Transport calls an application use case. The application calls an interface describing storage. A concrete infrastructure adapter fulfils that interface and interacts with the database.

At runtime, the use case ultimately causes a database action. But at the import level, the use case knows only the interface it needs, not PostgreSQL, Prisma, Express, or an ORM.

Watch this concise explanation before applying it.

Clean Architecture in TypeScript

Watch “Clean Architecture in TypeScript” by Donny Roufs for the practical reason interfaces sit between application logic and implementation details.

Start with dependency inversion, focusing on why a service should depend on a stable repository contract rather than a concrete repository. Then watch layer responsibilities, which distinguishes application, infrastructure, and presentation concerns and explains inward source dependencies.

The Clean Architecture Layers (Onion View) diagram captures this direction. Treat the rings as a dependency rule, not as a demand to create dozens of abstractions. In this lesson, the diagram’s “User Interface” corresponds to transport, while “Infrastructure” contains concrete adapters.

The onion diagram places entities and interfaces in the application core, with user-interface code and infrastructure implementations outside it; outer code may use the core, while the core must not depend on HTTP frameworks, SQL, or cloud services.

A concrete import policy for this project looks like this:

ModuleMay importMust not import
domain/Other domain modulesApplication, transport, infrastructure, Express, database packages
application/Domain modules and application portsTransport modules, infrastructure modules, Express, SQL drivers, process.env
transport/Application contracts and use casesDatabase client or repository implementation
infrastructure/Domain types and application portsTransport modules
main.tsAll of the aboveNothing is forbidden here; it is the intentional wiring point

The composition root is the one exception because its job is to know concrete details and connect them. It is not a “god module” containing business decisions; it is a small assembly point.

Read the following two parts for a precise formulation of the rule and an end-to-end example.

Clean Architecture Guide: Layers, Dependency Rule & vs Onion…

Read this guide from Generalist Programmer to connect the import rule to a TypeScript repository example. It is especially useful for distinguishing the direction of source dependencies from runtime behavior.

In the section “The Dependency Rule,” read the dependency rule. Focus on why an application-defined interface lets a use case request persistence without importing a database driver. Then move to “A concrete clean architecture example in TypeScript.” Read the complete example, including the entity, repository port, use case, in-memory adapter, and composition-root explanation. Track which modules import the repository interface and which module implements it.


3. Start with an application operation and its port

A practical way to design a feature is to begin with the application operation, not an Express route or database table. For the project-management capstone, define the operation as:

Create a named project for an owner, unless that owner already has a project with that name.

This is application policy. It says nothing about JSON, status codes, SQL, or which library generates IDs.

Keep domain data free of framework and persistence types

Create a small domain module for project data and its local invariant:

// src/domain/projects/project.ts

export type Project = Readonly<{
  id: string;
  ownerId: string;
  name: string;
}>;

type NewProject = {
  id: string;
  ownerId: string;
  name: string;
};

export function createProject(input: NewProject): Project {
  const name = input.name.trim();

  if (name.length === 0) {
    throw new Error("Project name cannot be empty");
  }

  if (name.length > 100) {
    throw new Error("Project name is too long");
  }

  return Object.freeze({
    id: input.id,
    ownerId: input.ownerId,
    name,
  });
}

This module has no dependency on Node or any package. It can be imported by application logic and by infrastructure adapters that need to map a database row into a Project.

For a service with mostly straightforward CRUD behavior, a small type and constructor function is enough. You do not need to turn every data shape into a rich class to have useful module boundaries.

Let the application define what it needs from storage

The application needs to check for an existing project and persist a new one. It should describe that need in its own vocabulary:

// src/application/projects/ports/project-repository.ts

import type { Project } from "../../../domain/projects/project.js";

export interface ProjectRepository {
  findByOwnerAndName(
    ownerId: string,
    name: string,
  ): Promise<Project | null>;

  insert(project: Project): Promise<void>;
}

This is a port: an interface owned by the code with the need.

Notice what is absent:

  • No SQL query strings
  • No Pool type from pg
  • No Prisma model
  • No persistence-specific return value such as rowCount
  • No HTTP request or response type

The method names and parameters describe what the use case needs. A PostgreSQL adapter may implement them. An in-memory adapter used in tests may implement them. The application does not need to care which one it receives.

ID generation is also an external capability. Rather than importing Node’s crypto module into the use case, make that dependency explicit:

// src/application/shared/id-generator.ts

export interface IdGenerator {
  next(): string;
}

The interface should not be created merely because an implementation might change. Create a port when application code needs a capability outside its boundary: persistence, clock access, ID generation, password hashing, email delivery, and similar concerns.

Write the use case in application terms

// src/application/projects/create-project.ts

import {
  createProject,
  type Project,
} from "../../domain/projects/project.js";
import type { IdGenerator } from "../shared/id-generator.js";
import type { ProjectRepository } from "./ports/project-repository.js";

export type CreateProjectInput = {
  ownerId: string;
  name: string;
};

export type CreateProjectOutput = {
  id: string;
  name: string;
};

export class CreateProjectUseCase {
  constructor(
    private readonly projects: ProjectRepository,
    private readonly ids: IdGenerator,
  ) {}

  async execute(
    input: CreateProjectInput,
  ): Promise<CreateProjectOutput> {
    const candidate: Project = createProject({
      id: this.ids.next(),
      ownerId: input.ownerId,
      name: input.name,
    });

    const existing = await this.projects.findByOwnerAndName(
      candidate.ownerId,
      candidate.name,
    );

    if (existing) {
      throw new Error("Project name is already in use");
    }

    await this.projects.insert(candidate);

    return {
      id: candidate.id,
      name: candidate.name,
    };
  }
}

This class is the architectural center of the feature:

  • Its input is application data, not Request.
  • Its output is application data, not Response.
  • It can enforce sequencing and rules.
  • Its only external dependencies are interfaces it receives.
  • It cannot accidentally run a database query because it has no database client.

The CreateProjectOutput type is deliberate. Returning a small result DTO avoids leaking an internal object shape as the public API by accident. Transport decides how that result is represented in JSON.

For now, the generic Error keeps the example focused on boundaries. The next lesson will make error propagation and diagnostic context more deliberate.


4. Make transport a translator, not a second application layer

Transport code knows the protocol. For an HTTP API, that means routes, request bodies, authentication context, status codes, headers, and JSON responses.

A transport handler should perform this narrow translation:

  1. Obtain request-specific data, such as the authenticated user ID.
  2. Validate enough protocol shape to construct the use-case input.
  3. Call the use case.
  4. Translate its successful output into an HTTP response.
  5. Delegate unexpected failures to centralized error handling.

Here is an Express-oriented handler factory. It assumes authentication middleware has attached an auth object to the request; the authentication mechanism itself is intentionally outside this feature.

// src/transport/http/projects/create-project-handler.ts

import type {
  NextFunction,
  Request,
  Response,
} from "express";
import type { CreateProjectUseCase } from "../../../application/projects/create-project.js";

type AuthenticatedRequest = Request & {
  auth: {
    userId: string;
  };
};

export function makeCreateProjectHandler(
  createProject: CreateProjectUseCase,
) {
  return async function createProjectHandler(
    req: AuthenticatedRequest,
    res: Response,
    next: NextFunction,
  ): Promise<void> {
    const body = req.body as { name?: unknown };

    if (typeof body.name !== "string") {
      res.status(400).json({
        error: "name must be a string",
      });
      return;
    }

    try {
      const result = await createProject.execute({
        ownerId: req.auth.userId,
        name: body.name,
      });

      res.status(201).json({
        data: result,
      });
    } catch (error: unknown) {
      next(error);
    }
  };
}

This handler may import Express because it is a transport module. It may choose 201 because that is an HTTP concern. But it does not decide how duplicates are detected and it does not import a repository or pg client.

The body check above is intentionally minimal. In the HTTP API module, you will replace ad hoc request checks with schema-based validation and transformation at the API boundary. The key boundary remains the same: the schema produces a typed use-case input before application code runs.

A route-registration module can stay equally small:

// src/transport/http/projects/project-routes.ts

import { Router } from "express";
import type { CreateProjectUseCase } from "../../../application/projects/create-project.js";
import { makeCreateProjectHandler } from "./create-project-handler.js";

export function registerProjectRoutes(
  router: Router,
  createProject: CreateProjectUseCase,
): void {
  router.post(
    "/projects",
    makeCreateProjectHandler(createProject),
  );
}

Avoid both extremes:

  • A controller that runs SQL directly bypasses the application boundary.
  • A controller that merely forwards an entire Request object to a use case hides HTTP coupling rather than removing it.

Passing a small object such as { ownerId, name } is explicit, easy to test, and usable by non-HTTP callers.


5. Infrastructure implements the port without redefining it

The infrastructure module contains technology-specific code. For the moment, an in-memory repository lets you run the feature before PostgreSQL is introduced:

// src/infrastructure/persistence/in-memory-project-repository.ts

import type { Project } from "../../domain/projects/project.js";
import type { ProjectRepository } from "../../application/projects/ports/project-repository.js";

export class InMemoryProjectRepository
  implements ProjectRepository {
  private readonly projects = new Map<string, Project>();

  async findByOwnerAndName(
    ownerId: string,
    name: string,
  ): Promise<Project | null> {
    for (const project of this.projects.values()) {
      if (
        project.ownerId === ownerId &&
        project.name === name
      ) {
        return project;
      }
    }

    return null;
  }

  async insert(project: Project): Promise<void> {
    this.projects.set(project.id, project);
  }
}

And a Node-specific ID adapter can fulfil IdGenerator:

// src/infrastructure/ids/crypto-id-generator.ts

import { randomUUID } from "node:crypto";
import type { IdGenerator } from "../../application/shared/id-generator.js";

export class CryptoIdGenerator implements IdGenerator {
  next(): string {
    return randomUUID();
  }
}

These adapters import application contracts because they implement them. The reverse import would break the boundary:

// Do not place this in application code.
import { Pool } from "pg";
import { InMemoryProjectRepository } from "../infrastructure/persistence/in-memory-project-repository.js";

When you reach PostgreSQL persistence, you will add a PostgresProjectRepository that implements the same ProjectRepository contract. The CreateProjectUseCase should not change merely because storage does.

This does not mean that all databases are interchangeable in every meaningful sense. Transaction behavior, query capabilities, and performance characteristics still matter. The boundary prevents accidental coupling; it does not erase real technical tradeoffs.


6. Wire concrete details only at the composition root

Your previous main.ts already has a natural composition-root role: it loads validated configuration, initializes dependencies, creates the HTTP application, and starts the server.

// src/main.ts

import { loadConfig } from "./config/env.js";
import { CreateProjectUseCase } from "./application/projects/create-project.js";
import { CryptoIdGenerator } from "./infrastructure/ids/crypto-id-generator.js";
import { InMemoryProjectRepository } from "./infrastructure/persistence/in-memory-project-repository.js";
import { createHttpApp } from "./transport/http/create-http-app.js";

async function main(): Promise<void> {
  const config = loadConfig();

  const projects = new InMemoryProjectRepository();
  const ids = new CryptoIdGenerator();

  const createProject = new CreateProjectUseCase(projects, ids);

  const app = createHttpApp({
    createProject,
  });

  app.listen(config.PORT, () => {
    console.info(`Listening on port ${config.PORT}`);
  });
}

void main().catch((error: unknown) => {
  console.error("Service failed to start", error);
  process.exitCode = 1;
});

This is dependency injection in its simplest form: constructors receive the dependencies they use. A DI container is not necessary at this stage. Explicit construction has several advantages:

  • You can see the running system’s wiring in one place.
  • Application code does not create hidden global dependencies.
  • Tests can provide a fake repository and deterministic ID generator.
  • Switching an adapter changes the composition root rather than a use case.

A focused unit test can now exercise the application operation without Express or a database:

const projects = new InMemoryProjectRepository();

const ids = {
  next: () => "project-123",
};

const createProject = new CreateProjectUseCase(projects, ids);

const result = await createProject.execute({
  ownerId: "user-1",
  name: "Launch plan",
});

console.assert(result.id === "project-123");

You will build this kind of controlled testing more systematically in the testing module. For now, use it as a design signal: if testing the duplicate-name rule requires setting up an HTTP server, your boundary is probably in the wrong place.

A pragmatic project layout

A small initial layout can make the allowed imports obvious:

src/
  domain/
    projects/
      project.ts
  application/
    projects/
      create-project.ts
      ports/
        project-repository.ts
    shared/
      id-generator.ts
  transport/
    http/
      projects/
        create-project-handler.ts
        project-routes.ts
  infrastructure/
    ids/
      crypto-id-generator.ts
    persistence/
      in-memory-project-repository.ts
  config/
    env.ts
  main.ts

Folders alone do not enforce architecture. TypeScript path aliases also make imports prettier but do not prevent prohibited imports. Enforce the boundary through review and, once the project grows, static import rules in linting or dependency-analysis tooling.

The highest-value rules to protect are simple:

  • application modules cannot import express, pg, Prisma, AWS SDKs, or infrastructure paths;
  • transport handlers cannot import a database client;
  • ports live beside the application code that needs them;
  • concrete adapter creation happens in main.ts, not inside use cases or controllers;
  • avoid a vague shared/ folder becoming a place where unrelated dependencies accumulate.

Key takeaways

A clean TypeScript boundary is not primarily a collection of folders. It is an import rule with clear responsibilities:

  • Transport translates HTTP concerns into use-case input and application output into HTTP responses.
  • Application holds the feature’s operation, sequencing, and business rules. It knows no HTTP framework, database, or environment variables.
  • Infrastructure implements application-owned ports using real technology such as Node APIs or persistence.
  • Composition root code is the only place that deliberately knows both use cases and concrete adapters.
  • Interfaces such as ProjectRepository belong with the application need, not with the database implementation.
  • Explicit dependency injection makes boundaries testable and makes adapter replacement local.

Next, you will build on this structure by implementing centralized error propagation that preserves stack traces and operational context while keeping HTTP responses consistent.

Can't find a good explanation? Sign up and we'll make it for you

Sign up