| |

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

LayerResponsibilityDepends On
DomainBusiness rulesNothing
ApplicationUse cases, orchestrationDomain
InfrastructureDatabase, external servicesApplication, Domain
PresentationHTTP, CLI, UIApplication, Domain

The Port Types

TypeDirectionExamples
Driving adapterCalls into the coreHTTP controller, CLI command
Driven adapterCalled by the coreRepository, email sender, payment gateway
PortInterface owned by the coreUserRepository, PaymentGateway

The Type-Level Safety Patterns

PatternPurpose
readonly propertiesPrevent mutation across boundaries
Discriminated unionsPrevent impossible states
Branded typesPrevent mixing semantically different values
Result typesMake errors explicit in the type system

The Enforcement Tools

ToolEnforces
Import graphDirection of dependencies
exports fieldPackage-level boundaries
eslint-plugin-boundariesLayer rules within a package
eslint-plugin-importModule boundaries
tsconfig project referencesCompilation 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

PitfallWhy It HappensFix
Circular dependenciesNo layer rulesEnable eslint-plugin-boundaries
Hard-to-test functionsDependencies instantiated insideInject through parameters
Impossible statesOptional fields for exclusive casesUse discriminated unions
Mutation across boundariesNo readonly on typesAdd readonly
Domain knows about infrastructureImport rule not enforcedEnforce dependency direction
Test requires full setupNo in-memory adaptersCreate 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

ItemValue
Domain layerBusiness rules, no dependencies
Application layerUse cases, ports
Infrastructure layerDatabase, external services
Presentation layerHTTP, CLI, UI
Dependency directionPresentation โ†’ Application โ†’ Domain
PortInterface owned by the core
Driving adapterCalls into the core
Driven adapterCalled by the core
Composition rootWhere concrete implementations are wired
Enforcementeslint-plugin-boundaries, import graph
Type safetyreadonly, 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 exports field in package.json, and lint rules like eslint-plugin-boundaries make 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. readonly on 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!