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:
| Area | Primary question it answers | Should know about |
|---|---|---|
| Transport | How does this protocol request become an application call, and how is a result returned? | HTTP routes, headers, request and response objects, status codes |
| Application | What operation does the system perform, in what order, and under which rules? | Use-case input/output, domain types, interfaces it needs |
| Infrastructure | How is a needed capability actually performed? | PostgreSQL, a query client, filesystem, email SDKs, Node APIs |
| Composition root | Which 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.
{"type":"video","title":"Clean Architecture in TypeScript","learning_duration":426,"video_id":"f7Su4KoqSio","par_intro":"Watch “Clean Architecture in TypeScript” by Donny Roufs for the practical reason interfaces sit between application logic and implementation details.","par_directions":"Start with <span data-type=\"resource_video_timerange\" data-resource-subitem-id=\"e9131b23\" data-range-start=\"125\" data-range-end=\"287\">dependency inversion</span>, focusing on why a service should depend on a stable repository contract rather than a concrete repository. Then watch <span data-type=\"resource_video_timerange\" data-resource-subitem-id=\"aa7475ac\" data-range-start=\"376\" data-range-end=\"640\">layer responsibilities</span>, which distinguishes application, infrastructure, and presentation concerns and explains inward source dependencies.","video_duration":3499,"isV2":true,"blockId":"7bc868d4-260b-43a7-b5cc-a1fa63adda6b","lessonId":"dc0851a7-e949-4c36-bfc3-ae47296c76fa"}
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.
{"type":"image","url":"https://learn.microsoft.com/en-us/dotnet/architecture/modern-web-apps-azure/media/image5-7.png","caption":"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.","isV2":true,"blockId":"accfbe8b-8572-4552-8da4-846d3e0c0fdb","lessonId":"dc0851a7-e949-4c36-bfc3-ae47296c76fa"}
A concrete import policy for this project looks like this:
| Module | May import | Must not import |
|---|---|---|
domain/ | Other domain modules | Application, transport, infrastructure, Express, database packages |
application/ | Domain modules and application ports | Transport modules, infrastructure modules, Express, SQL drivers, process.env |
transport/ | Application contracts and use cases | Database client or repository implementation |
infrastructure/ | Domain types and application ports | Transport modules |
main.ts | All of the above | Nothing 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.
{"type":"reading","par_intro":"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.","par_directions":"In the section “The Dependency Rule,” read <span data-type=\"resource_reading_textrange\" data-resource-subitem-id=\"4e37ff36\" data-range-start=\"Everything in clean architecture follows from one rule\" data-range-end=\"both sides depend on the interface that lives in the inner layer.\">the dependency rule</span>. Focus on why an application-defined interface lets a use case request persistence without importing a database driver.\n\nThen move to “A concrete clean architecture example in TypeScript.” Read <span data-type=\"resource_reading_textrange\" data-resource-subitem-id=\"b03d3dcd\" data-range-start=\"Diagrams are easy to nod along to and hard to apply, so let us build one vertical slice of a real feature: registering a user.\" data-range-end=\"The entity, the port, and the use case never change.\">the complete example</span>, 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.","learning_duration":"10 minutes","url":"https://generalistprogrammer.com/tutorials/clean-architecture-complete-guide","title":"Clean Architecture Guide: Layers, Dependency Rule & vs Onion…","isV2":true,"blockId":"ca18b28b-232e-48f0-875d-06c35eb504b2","lessonId":"dc0851a7-e949-4c36-bfc3-ae47296c76fa"}
{
"type": "exercise",
"id": "4c43e01f-c5e9-49a9-b648-de3ef0406fe3"
}
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
Pooltype frompg - 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:
- Obtain request-specific data, such as the authenticated user ID.
- Validate enough protocol shape to construct the use-case input.
- Call the use case.
- Translate its successful output into an HTTP response.
- 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
Requestobject 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.
{
"type": "exercise",
"id": "354cb096-0f54-4c86-9086-bcb7f110f53e"
}
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.
{
"type": "exercise",
"id": "57b55fe1-1a6d-42cb-9a8f-242df81410e0"
}
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
ProjectRepositorybelong 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