TypeScript 105 ๐ท Real-World Architecture Patterns
TypeScript is a language. Architecture is what you do with it. Two codebases can use identical TypeScript features and produce entirely different results โ one maintainable for years, the other collapsing under its own weight within months. The difference is not the syntax. It is the structure: how the code is organized, how dependencies flow, and how the type system is used to enforce boundaries.
This chapter covers the architecture patterns that have emerged from real TypeScript codebases โ not the abstract patterns from textbooks, but the ones that solve problems teams actually encounter. Layered architecture, ports and adapters, module boundaries, dependency injection, and error handling. Each pattern is a response to a specific failure mode: the codebase that becomes impossible to test, the module that imports everything, the error that gets swallowed.
Key point: Architecture in TypeScript is enforced by the type system and the module system together. The type system prevents invalid data from crossing boundaries. The module system determines what can import what. A well-architected codebase uses both: types that make invalid states unrepresentable, and module boundaries that make illegal imports impossible.
Why architecture patterns matter
A small project can survive without architecture. A codebase that grows past a few thousand lines cannot. The problems that emerge are predictable: the circular dependency that breaks the build, the service that imports the entire application, the test that requires setting up five other services first.
The coupling problem. Without boundaries, every module can import every other module. A change to the database layer ripples into the UI. A change to the UI ripples into the database layer. The codebase becomes a single interconnected mass where no part can be understood in isolation. Architecture patterns introduce boundaries: layers, ports, modules. The boundaries constrain the flow of dependencies.
The testability problem. A function that directly calls a database, a web API, and a file system is hard to test. The test must set up all three dependencies. Architecture patterns invert the dependencies: the function depends on an interface, and the test provides a fake implementation. The function no longer knows or cares where the data comes from.
The representability problem. A type with status: string and optional data and error fields allows impossible states. Architecture patterns encourage types that make the impossible states impossible: discriminated unions, branded types, and readonly properties. The type system becomes a tool for enforcing invariants, not just a way to catch typos.
The error problem. An error thrown in one layer and caught in another loses context. An error returned as a value is explicit and type-checked. Architecture patterns determine where errors are thrown, where they are caught, and how they are represented in the type system.
The trade-off. Architecture patterns add ceremony. A function that could be written in ten lines becomes an interface, an implementation, a factory, and a test. The ceremony is the cost. The benefit is a codebase that can be modified without fear. The patterns are worth the cost when the codebase is large enough that modification without fear is a requirement.
a. Layered Architecture
Layered architecture divides the codebase into horizontal layers, each with a specific responsibility and a defined dependency direction. The canonical layers are presentation, application, domain, and infrastructure. Each layer depends only on the layers below it.
The domain layer contains the business rules. It has no dependencies on other layers. It defines entities, value objects, and domain services. The types in this layer express the business concepts: Order, PaymentMethod, Money. The domain layer does not know about HTTP, databases, or the UI.
The application layer orchestrates the domain. It defines use cases โ “place an order,” “cancel a subscription” โ and calls the domain objects to implement them. It depends on the domain layer, and it defines interfaces for the infrastructure it needs: a repository, a payment gateway, a notification service.
The infrastructure layer implements the interfaces defined by the application layer. It contains the actual database access, HTTP clients, and message queue producers. It depends on the application layer’s interfaces, not the other way around.
The presentation layer is the UI or the API. It calls the application layer’s use cases and renders the results. It depends on the application layer.
// ============================================
// DOMAIN LAYER
// ============================================
// domain/order.ts
export type OrderStatus = 'pending' | 'paid' | 'shipped' | 'cancelled';
export interface Order {
readonly id: string;
readonly items: readonly OrderItem[];
readonly status: OrderStatus;
readonly total: number;
}
export interface OrderItem {
readonly productId: string;
readonly quantity: number;
readonly price: number;
}
export function calculateTotal(items: readonly OrderItem[]): number {
return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}
// ============================================
// APPLICATION LAYER
// ============================================
// application/ports.ts
import type { Order } from '../domain/order';
export interface OrderRepository {
findById(id: string): Promise<Order | null>;
save(order: Order): Promise<void>;
}
export interface PaymentGateway {
charge(amount: number, token: string): Promise<{ transactionId: string }>;
}
// application/place-order.ts
import type { Order, OrderItem } from '../domain/order';
import { calculateTotal } from '../domain/order';
import type { OrderRepository, PaymentGateway } from './ports';
export interface PlaceOrderInput {
readonly items: readonly OrderItem[];
readonly paymentToken: string;
}
export async function placeOrder(
input: PlaceOrderInput,
repository: OrderRepository,
gateway: PaymentGateway,
): Promise<Order> {
const total = calculateTotal(input.items);
const payment = await gateway.charge(total, input.paymentToken);
const order: Order = {
id: payment.transactionId,
items: input.items,
status: 'paid',
total,
};
await repository.save(order);
return order;
}
// ============================================
// INFRASTRUCTURE LAYER
// ============================================
// infrastructure/postgres-order-repository.ts
import type { OrderRepository } from '../application/ports';
import type { Order } from '../domain/order';
import { Pool } from 'pg';
export class PostgresOrderRepository implements OrderRepository {
constructor(private pool: Pool) {}
async findById(id: string): Promise<Order | null> {
const result = await this.pool.query(
'SELECT * FROM orders WHERE id = $1',
[id],
);
return result.rows[0] ? mapRowToOrder(result.rows[0]) : null;
}
async save(order: Order): Promise<void> {
await this.pool.query(
'INSERT INTO orders (id, items, status, total) VALUES ($1, $2, $3, $4)',
[order.id, JSON.stringify(order.items), order.status, order.total],
);
}
}
function mapRowToOrder(row: Record<string, unknown>): Order {
return {
id: row.id as string,
items: JSON.parse(row.items as string) as OrderItem[],
status: row.status as OrderStatus,
total: row.total as number,
};
}
// ============================================
// PRESENTATION LAYER
// ============================================
// presentation/order-controller.ts
import type { Request, Response } from 'express';
import { placeOrder } from '../application/place-order';
import type { OrderRepository, PaymentGateway } from '../application/ports';
export function createOrderController(
repository: OrderRepository,
gateway: PaymentGateway,
) {
return async (req: Request, res: Response) => {
try {
const order = await placeOrder(
{ items: req.body.items, paymentToken: req.body.paymentToken },
repository,
gateway,
);
res.status(201).json(order);
} catch (error) {
res.status(400).json({ error: (error as Error).message });
}
};
}
The dependency direction is consistent: presentation depends on application, application depends on domain, and infrastructure depends on application’s interfaces. The domain layer depends on nothing. The infrastructure layer can be swapped โ Postgres for MySQL, Stripe for Adyen โ without changing the application or domain layers.
b. Ports and Adapters
Ports and adapters โ also called hexagonal architecture โ is a refinement of layered architecture. Instead of horizontal layers, it uses a core surrounded by adapters. The core contains the domain and application logic. The adapters connect the core to the outside world.
A port is an interface defined by the core that describes what the core needs from the outside. A driving adapter calls into the core โ an HTTP controller, a CLI command, a message queue consumer. A driven adapter is called by the core โ a database repository, an email sender, a payment gateway.
The difference from layered architecture is symmetry. Layered architecture has a top and a bottom. Ports and adapters has a center and a periphery. The core does not know whether it is being called by an HTTP request or a CLI command. The core does not know whether its payment port is implemented by Stripe or a mock.
// ============================================
// THE CORE
// ============================================
// core/ports.ts
export interface UserRepository {
findByEmail(email: string): Promise<User | null>;
save(user: User): Promise<void>;
}
export interface PasswordHasher {
hash(password: string): Promise<string>;
verify(password: string, hash: string): Promise<boolean>;
}
export interface EmailSender {
send(to: string, subject: string, body: string): Promise<void>;
}
// core/register-user.ts
export interface RegisterUserInput {
readonly email: string;
readonly password: string;
}
export async function registerUser(
input: RegisterUserInput,
deps: {
users: UserRepository;
hasher: PasswordHasher;
email: EmailSender;
},
): Promise<User> {
const existing = await deps.users.findByEmail(input.email);
if (existing) {
throw new Error('Email already registered');
}
const passwordHash = await deps.hasher.hash(input.password);
const user: User = {
id: crypto.randomUUID(),
email: input.email,
passwordHash,
createdAt: new Date(),
};
await deps.users.save(user);
await deps.email.send(user.email, 'Welcome', 'Thanks for signing up');
return user;
}
// ============================================
// DRIVING ADAPTERS
// ============================================
// adapters/http/register-controller.ts
export function createRegisterController(deps: RegisterUserDeps) {
return async (req: Request, res: Response) => {
const user = await registerUser(
{ email: req.body.email, password: req.body.password },
deps,
);
res.status(201).json({ id: user.id, email: user.email });
};
}
// adapters/cli/register-command.ts
export async function registerCommand(
email: string,
password: string,
deps: RegisterUserDeps,
) {
const user = await registerUser({ email, password }, deps);
console.log(`Registered user: ${user.email}`);
}
// ============================================
// DRIVEN ADAPTERS
// ============================================
// adapters/postgres/user-repository.ts
export class PostgresUserRepository implements UserRepository {
constructor(private pool: Pool) {}
async findByEmail(email: string): Promise<User | null> {
const result = await this.pool.query(
'SELECT * FROM users WHERE email = $1',
[email],
);
return result.rows[0] ? mapRow(result.rows[0]) : null;
}
async save(user: User): Promise<void> {
await this.pool.query(
'INSERT INTO users (id, email, password_hash, created_at) VALUES ($1, $2, $3, $4)',
[user.id, user.email, user.passwordHash, user.createdAt],
);
}
}
// adapters/bcrypt/password-hasher.ts
export class BcryptPasswordHasher implements PasswordHasher {
async hash(password: string): Promise<string> {
return bcrypt.hash(password, 12);
}
async verify(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
}
// adapters/smtp/email-sender.ts
export class SmtpEmailSender implements EmailSender {
async send(to: string, subject: string, body: string): Promise<void> {
await transporter.sendMail({ to, subject, text: body });
}
}
The core defines UserRepository, PasswordHasher, and EmailSender as ports. The adapters implement them. The core does not import any adapter. The adapters import the core’s ports. The dependency direction is inverted: the core owns the interfaces, the periphery owns the implementations.
This inversion is what makes testing possible. A test for registerUser provides in-memory implementations of the ports. No database, no SMTP server, no bcrypt. The test runs in milliseconds.
c. Module Boundaries and the Dependency Rule
The patterns above describe the shape of the code. The dependency rule describes how the shape is enforced. In TypeScript, module boundaries are enforced by three mechanisms: the import graph, the exports field in package.json, and lint rules.
The import graph is the primary mechanism. If the domain layer does not import the infrastructure layer, the domain layer cannot depend on the infrastructure. The direction is a convention until it is enforced by the module system.
The exports field in package.json enforces boundaries in a monorepo. A package’s exports field defines what other packages can import. If @my-org/domain only exports its public API, other packages cannot import its internal modules. This is the package-level equivalent of a module boundary.
Lint rules enforce the direction of imports within a package. The eslint-plugin-boundaries and eslint-plugin-import rules can be configured to reject imports that violate the architectural rules:
{
"rules": {
"boundaries/element-types": [
"error",
{
"default": "disallow",
"rules": [
{
"from": "domain",
"allow": []
},
{
"from": "application",
"allow": ["domain"]
},
{
"from": "infrastructure",
"allow": ["application", "domain"]
},
{
"from": "presentation",
"allow": ["application", "domain"]
}
]
}
]
}
}
The rule states that domain can import nothing, application can import domain, infrastructure can import application and domain, and presentation can import application and domain. Any import that violates these rules is a lint error.
The combination of the import graph, the exports field, and lint rules makes the dependency direction a contract rather than a convention. A developer who tries to import the infrastructure from the domain gets a lint error before the code is committed. The architecture is enforced by the tooling.
Complete Example Session
This session builds a small feature using layered architecture with ports and adapters, and enforces the boundaries with lint rules.
// ============================================
// PART 1: THE DOMAIN
// ============================================
// src/domain/task.ts
export type TaskStatus = 'todo' | 'in-progress' | 'done';
export type TaskPriority = 'low' | 'medium' | 'high';
export interface Task {
readonly id: string;
readonly title: string;
readonly status: TaskStatus;
readonly priority: TaskPriority;
readonly createdAt: Date;
}
export function isOverdue(task: Task, now: Date): boolean {
if (task.status === 'done') return false;
const daysSinceCreation = (now.getTime() - task.createdAt.getTime()) / 86400000;
return daysSinceCreation > 7 && task.priority === 'high';
}
// ============================================
// PART 2: THE APPLICATION PORTS
// ============================================
// src/application/ports.ts
import type { Task, TaskPriority, TaskStatus } from '../domain/task';
export interface TaskRepository {
findAll(): Promise<readonly Task[]>;
findById(id: string): Promise<Task | null>;
save(task: Task): Promise<void>;
delete(id: string): Promise<void>;
}
export interface TaskNotifier {
notifyOverdue(tasks: readonly Task[]): Promise<void>;
}
// ============================================
// PART 3: THE APPLICATION USE CASES
// ============================================
// src/application/create-task.ts
import type { Task, TaskPriority } from '../domain/task';
import type { TaskRepository } from './ports';
export interface CreateTaskInput {
readonly title: string;
readonly priority: TaskPriority;
}
export async function createTask(
input: CreateTaskInput,
repository: TaskRepository,
): Promise<Task> {
const task: Task = {
id: crypto.randomUUID(),
title: input.title,
status: 'todo',
priority: input.priority,
createdAt: new Date(),
};
await repository.save(task);
return task;
}
// src/application/check-overdue.ts
import type { TaskRepository, TaskNotifier } from './ports';
import { isOverdue } from '../domain/task';
export async function checkOverdueTasks(
repository: TaskRepository,
notifier: TaskNotifier,
now: Date = new Date(),
): Promise<number> {
const tasks = await repository.findAll();
const overdue = tasks.filter((task) => isOverdue(task, now));
if (overdue.length > 0) {
await notifier.notifyOverdue(overdue);
}
return overdue.length;
}
// ============================================
// PART 4: THE INFRASTRUCTURE
// ============================================
// src/infrastructure/in-memory-task-repository.ts
import type { Task } from '../domain/task';
import type { TaskRepository } from '../application/ports';
export class InMemoryTaskRepository implements TaskRepository {
private tasks = new Map<string, Task>();
async findAll(): Promise<readonly Task[]> {
return Array.from(this.tasks.values());
}
async findById(id: string): Promise<Task | null> {
return this.tasks.get(id) ?? null;
}
async save(task: Task): Promise<void> {
this.tasks.set(task.id, task);
}
async delete(id: string): Promise<void> {
this.tasks.delete(id);
}
}
// ============================================
// PART 5: THE PRESENTATION
// ============================================
// src/presentation/task-controller.ts
import type { Request, Response } from 'express';
import { createTask } from '../application/create-task';
import { checkOverdueTasks } from '../application/check-overdue';
import type { TaskRepository, TaskNotifier } from '../application/ports';
export function createTaskController(
repository: TaskRepository,
notifier: TaskNotifier,
) {
return {
async create(req: Request, res: Response) {
const task = await createTask(
{ title: req.body.title, priority: req.body.priority },
repository,
);
res.status(201).json(task);
},
async checkOverdue(_req: Request, res: Response) {
const count = await checkOverdueTasks(repository, notifier);
res.json({ overdueCount: count });
},
};
}
// ============================================
// PART 6: THE COMPOSITION ROOT
// ============================================
// src/main.ts
import express from 'express';
import { InMemoryTaskRepository } from './infrastructure/in-memory-task-repository';
import { createTaskController } from './presentation/task-controller';
import type { TaskNotifier } from './application/ports';
import type { Task } from './domain/task';
class ConsoleNotifier implements TaskNotifier {
async notifyOverdue(tasks: readonly Task[]): Promise<void> {
console.log(`${tasks.length} overdue tasks`);
}
}
const repository = new InMemoryTaskRepository();
const notifier = new ConsoleNotifier();
const controller = createTaskController(repository, notifier);
const app = express();
app.use(express.json());
app.post('/tasks', controller.create);
app.post('/tasks/check-overdue', controller.checkOverdue);
app.listen(3000);
// ============================================
// PART 7: THE TEST
// ============================================
// src/application/create-task.test.ts
import { describe, it, expect } from 'vitest';
import { createTask } from './create-task';
import { InMemoryTaskRepository } from '../infrastructure/in-memory-task-repository';
describe('createTask', () => {
it('creates a task with todo status', async () => {
const repository = new InMemoryTaskRepository();
const task = await createTask(
{ title: 'Write docs', priority: 'high' },
repository,
);
expect(task.status).toBe('todo');
expect(task.title).toBe('Write docs');
expect(task.priority).toBe('high');
const saved = await repository.findById(task.id);
expect(saved).toEqual(task);
});
});
// ============================================
// PART 8: THE LINT BOUNDARY
// ============================================
// eslint.config.mjs
import boundaries from 'eslint-plugin-boundaries';
export default [
{
plugins: { boundaries },
settings: {
'boundaries/elements': [
{ type: 'domain', pattern: 'src/domain/*' },
{ type: 'application', pattern: 'src/application/*' },
{ type: 'infrastructure', pattern: 'src/infrastructure/*' },
{ type: 'presentation', pattern: 'src/presentation/*' },
],
},
rules: {
'boundaries/element-types': [
'error',
{
default: 'disallow',
rules: [
{ from: 'domain', allow: [] },
{ from: 'application', allow: ['domain'] },
{ from: 'infrastructure', allow: ['application', 'domain'] },
{ from: 'presentation', allow: ['application', 'domain'] },
],
},
],
},
},
];
// ============================================
// PART 9: THE TYPE-LEVEL BOUNDARY
// ============================================
// The domain types are readonly, which prevents
// the application layer from mutating them.
//
// The repository interface uses readonly arrays,
// which prevents the application from assuming
// the array is mutable.
//
// The Task type has no methods that mutate it.
// Mutation happens by creating a new Task object
// and saving it.
// ============================================
// PART 10: THE DEPENDENCY GRAPH
// ============================================
// domain: no imports
// application: imports domain
// infrastructure: imports application and domain
// presentation: imports application and domain
//
// The direction is enforced by ESLint.
// The type system enforces the shapes.
The ten parts cover the domain, the application ports, the application use cases, the infrastructure, the presentation, the composition root, the test, the lint boundary, the type-level boundary, and the dependency graph.
Quick Reference
The Layers
| Layer | Responsibility | Depends On |
|---|---|---|
| Domain | Business rules | Nothing |
| Application | Use cases, orchestration | Domain |
| Infrastructure | Database, external services | Application, Domain |
| Presentation | HTTP, CLI, UI | Application, Domain |
The Port Types
| Type | Direction | Examples |
|---|---|---|
| Driving adapter | Calls into the core | HTTP controller, CLI command |
| Driven adapter | Called by the core | Repository, email sender, payment gateway |
| Port | Interface owned by the core | UserRepository, PaymentGateway |
The Type-Level Safety Patterns
| Pattern | Purpose |
|---|---|
readonly properties | Prevent mutation across boundaries |
| Discriminated unions | Prevent impossible states |
| Branded types | Prevent mixing semantically different values |
| Result types | Make errors explicit in the type system |
The Enforcement Tools
| Tool | Enforces |
|---|---|
| Import graph | Direction of dependencies |
exports field | Package-level boundaries |
eslint-plugin-boundaries | Layer rules within a package |
eslint-plugin-import | Module boundaries |
tsconfig project references | Compilation boundaries |
Best Practices
โ Do This:
// Define ports in the core
export interface UserRepository { ... } // โ
// Implement ports in the infrastructure
export class PostgresUserRepository implements UserRepository { } // โ
// Inject dependencies through the composition root
const controller = createController(repository, notifier); // โ
// Use readonly on domain types
interface Task { readonly id: string; } // โ
// Use discriminated unions for state
type State = { status: 'loading' } | { status: 'success'; data: T }; // โ
// Enforce boundaries with lint rules
{ "rules": { "boundaries/element-types": ["error", { ... }] } } // โ
โ Don’t Do This:
// Don't import infrastructure from the domain
import { PostgresRepository } from '../infrastructure/...'; // โ
// Don't let the domain depend on HTTP types
import type { Request } from 'express'; // โ
// Don't instantiate dependencies inside functions
async function createUser() {
const repo = new PostgresUserRepository(); // hard to test // โ
}
// Don't use mutable arrays on domain types
interface Task { items: Item[]; } // should be readonly // โ
// Don't skip the composition root
// Modules should not construct their own dependencies. // โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Circular dependencies | No layer rules | Enable eslint-plugin-boundaries |
| Hard-to-test functions | Dependencies instantiated inside | Inject through parameters |
| Impossible states | Optional fields for exclusive cases | Use discriminated unions |
| Mutation across boundaries | No readonly on types | Add readonly |
| Domain knows about infrastructure | Import rule not enforced | Enforce dependency direction |
| Test requires full setup | No in-memory adapters | Create test doubles for ports |
Real-World Examples
1. Domain Entity
export interface User { readonly id: string; readonly email: string; }
2. Port Interface
export interface UserRepository {
findByEmail(email: string): Promise<User | null>;
}
3. Use Case
export async function registerUser(input: RegisterInput, deps: Deps): Promise<User> { }
4. Driven Adapter
export class PostgresUserRepository implements UserRepository { }
5. Driving Adapter
export function createRegisterController(deps: Deps) { return async (req, res) => { }; }
6. Composition Root
const repository = new PostgresUserRepository(pool);
const controller = createRegisterController({ repository });
7. Test Double
class InMemoryUserRepository implements UserRepository { }
8. Discriminated Union
type State<T> = { status: 'loading' } | { status: 'success'; data: T };
9. Lint Boundary
{ "from": "domain", "allow": [] }
10. Readonly Domain
interface Task { readonly items: readonly OrderItem[]; }
Visual
The Layered Architecture
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ PRESENTATION โ
โ HTTP controllers, CLI commands, UI โ
โ depends on: application, domain โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ APPLICATION โ
โ Use cases, ports (interfaces) โ
โ depends on: domain โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ DOMAIN โ
โ Entities, value objects, business rules โ
โ depends on: nothing โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ INFRASTRUCTURE โ
โ Repositories, external services โ
โ depends on: application, domain โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Ports and Adapters
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ PORTS AND ADAPTERS โ
โ โ
โ Driving adapter โโ> Core โโ> Driven adapter โ
โ โ
โ HTTP controller โโโ โ
โ CLI command โโโโโโโผโโ> registerUser โโโฌโโ> โ
โ Message queue โโโโโ (core) โ โ
โ โโโ> โ
โ โโโ> โ
โ โ
โ Driven adapters: โ
โ PostgresUserRepository โ
โ BcryptPasswordHasher โ
โ SmtpEmailSender โ
โ โ
โ The core defines the ports. โ
โ The adapters implement them. โ
โ The core does not know the adapters exist. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Dependency Rule
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ DEPENDENCY DIRECTION โ
โ โ
โ presentation โโ> application โโ> domain โ
โ infrastructure โโ> application โโ> domain โ
โ โ
โ โ domain โโ> infrastructure โ
โ โ domain โโ> application โ
โ โ application โโ> infrastructure โ
โ โ application โโ> presentation โ
โ โ
โ Enforced by: โ
โ - import graph โ
โ - eslint-plugin-boundaries โ
โ - package exports field โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Composition Root
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ COMPOSITION ROOT โ
โ โ
โ main.ts โ
โ โโ Create infrastructure adapters โ
โ โ โโ new PostgresUserRepository(pool) โ
โ โ โโ new BcryptPasswordHasher() โ
โ โ โโ new SmtpEmailSender() โ
โ โ โ
โ โโ Create controllers โ
โ โ โโ createRegisterController(deps) โ
โ โ โ
โ โโ Wire the routes โ
โ โโ app.post('/register', controller) โ
โ โ
โ Only the composition root knows the โ
โ concrete implementations. The rest of โ
โ the code depends on interfaces. โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| Domain layer | Business rules, no dependencies |
| Application layer | Use cases, ports |
| Infrastructure layer | Database, external services |
| Presentation layer | HTTP, CLI, UI |
| Dependency direction | Presentation โ Application โ Domain |
| Port | Interface owned by the core |
| Driving adapter | Calls into the core |
| Driven adapter | Called by the core |
| Composition root | Where concrete implementations are wired |
| Enforcement | eslint-plugin-boundaries, import graph |
| Type safety | readonly, discriminated unions |
Key takeaways:
- Architecture is the structure of a codebase, not the features of a language. Two codebases can use identical TypeScript features and produce entirely different results. The difference is how the code is organized, how dependencies flow, and how the type system is used to enforce boundaries.
- Layered architecture divides the codebase into horizontal layers with a defined dependency direction. Domain has no dependencies. Application depends on domain. Infrastructure and presentation depend on application and domain. The direction is a contract.
- Ports and adapters invert the dependencies. The core defines the interfaces it needs โ the ports. The periphery provides the implementations โ the adapters. The core does not know whether it is being called by an HTTP request or a CLI command, or whether its repository is backed by Postgres or an in-memory map.
- The composition root is where the concrete implementations are wired. Only one file in the application knows which repository, which hasher, and which email sender are used. The rest of the code depends on interfaces. This is what makes testing possible: the test provides its own implementations.
- The dependency rule is enforced by the tooling. The import graph, the
exportsfield inpackage.json, and lint rules likeeslint-plugin-boundariesmake the direction a contract rather than a convention. A developer who tries to import the infrastructure from the domain gets a lint error before the code is committed. - The type system enforces invariants.
readonlyon domain types prevents mutation across boundaries. Discriminated unions prevent impossible states. Branded types prevent mixing semantically different values. The type system is not just a way to catch typos โ it is a way to make invalid states unrepresentable. - Architecture patterns add ceremony. A function that could be written in ten lines becomes an interface, an implementation, a factory, and a test. The ceremony is the cost. The benefit is a codebase that can be modified without fear. The patterns are worth the cost when the codebase is large enough that modification without fear is a requirement.
Remember: Architecture is the set of decisions that determine whether a codebase is maintainable in two years. TypeScript gives you the tools โ interfaces, readonly, discriminated unions, module boundaries โ but the decisions are yours. Layered architecture, ports and adapters, and enforced boundaries are not the only patterns. They are patterns that have survived contact with real codebases. Use them when the codebase is large enough to need them. The ceremony is the cost. The maintainability is the return.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!