Dataslope logoDataslope

Scalable Architecture

Structure large TypeScript systems with layered architecture and dependency inversion.

As codebases grow, they become harder to navigate, test, and change. Functions depend on other functions, modules import from everywhere, and changes ripple unpredictably. Without structure, you end up with a tangled mess where every change risks breaking something distant.

This chapter introduces layered architecture — a pattern for organizing code into distinct layers (domain, application, infrastructure) with clear boundaries and dependencies flowing in one direction. You'll learn how to use TypeScript's type system to enforce these boundaries, prevent circular dependencies, and make large systems understandable and maintainable.


The Problem: Tangled Dependencies

Consider a typical application without layering:

user-service.ts → database.ts → config.ts → user-service.ts
     ↓                ↓               ↓
order-service.ts → http-client.ts → logger.ts

Everything depends on everything else. Testing is hard (you need to mock the database, HTTP, and logger). Changing one module breaks others unpredictably. The codebase becomes a big ball of mud.


Layered Architecture

A layered architecture divides code into three (or more) layers:

  1. Domain layer: Pure business logic and types. No I/O, no frameworks, no infrastructure. Just functions and data structures that model your problem domain.
  2. Application layer: Use cases that orchestrate domain logic. Defines what the system does, but not how it talks to the outside world.
  3. Infrastructure layer: Concrete implementations of I/O: databases, HTTP clients, file systems, third-party APIs.

Dependencies flow in one direction: Infrastructure → Application → Domain. The domain knows nothing about infrastructure.


Domain Layer: Pure Business Logic

The domain layer contains types and functions that model your problem. It depends on nothing outside itself.

Code Block
TypeScript 5.7

Notice:

  • No imports. No database, no HTTP, no file system.
  • Pure functions: given the same inputs, they return the same outputs.
  • Easy to test: no mocks needed.

Application Layer: Use Cases

The application layer orchestrates domain logic to fulfill use cases. It defines interfaces (contracts) for infrastructure, but doesn't implement them.

Code Block
TypeScript 5.7

Here:

  • application.ts defines UserRepository (a port — an interface).
  • infrastructure.ts provides InMemoryUserRepository (an adapter — a concrete implementation).
  • main.ts wires them together.

This is dependency inversion: the application depends on an abstraction (the interface), not a concrete implementation. You can swap InMemoryUserRepository for PostgresUserRepository without changing application.ts.


Dependency Inversion in TypeScript

Dependency inversion is simple in TypeScript: define an interface (or a function type) in the application layer, and require implementations to match.

Code Block
TypeScript 5.7

The application layer doesn't import a concrete logger. It defines what a logger looks like (the Logger type), and accepts any implementation. Tests can pass a no-op or in-memory logger; production can pass a file logger or cloud logger.


Enforcing Boundaries with Modules

Use module boundaries to enforce layering:

  • The domain module exports only domain types and functions.
  • The application module exports use cases and ports (interfaces).
  • The infrastructure module exports adapters (implementations of ports).
Code Block
TypeScript 5.7

Notice:

  • domain.ts has no imports. It's pure.
  • application.ts imports from domain, defines ports, and orchestrates.
  • infrastructure.ts imports from both domain and application, providing concrete implementations.
  • main.ts wires everything together (this is the composition root).

Barrel Files for Clean Exports

A barrel file (index.ts) re-exports only the public API of a module, hiding internal details:

Code Block
TypeScript 5.7

Consumers import from ./user, not ./user/domain or ./user/application. This keeps internal structure private and allows refactoring without breaking consumers.


Making Impossible States Unrepresentable

Use types to enforce invariants at module boundaries. For example, ensure that an Order can only be created through a validated constructor:

Code Block
TypeScript 5.7

Here, Order is a branded type. You can't construct an Order without going through createOrder, which validates inputs. Invalid orders are impossible to represent in the type system.


Challenge: Implement a Layered Todo Service

Challenge
TypeScript 5.7
Layered Todo Service

Build a layered todo service:

  • domain.ts: Define Todo (id: string, title: string, done: boolean) and a toggleTodo function.
  • application.ts: Define TodoRepository interface with findById and save methods. Implement toggleTodoById(repo, id) that retrieves, toggles, and saves.
  • infrastructure.ts: Implement InMemoryTodoRepository.
  • main.ts: Create a todo, toggle it, and print the result.

Module Path Aliases (Brief Mention)

In large projects, relative imports (../../domain) become unwieldy. TypeScript's paths in tsconfig.json lets you define aliases:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@domain/*": ["src/domain/*"],
      "@application/*": ["src/application/*"]
    }
  }
}

Then you can write:

import { User } from "@domain/user";
import { getUserById } from "@application/user-use-cases";

This makes imports consistent and easier to refactor. (Details vary by build tool; consult your tooling docs.)


Monorepo Type Sharing (Teaser)

In a monorepo (multiple packages in one repo), you can share types across packages. For example, a @myapp/types package exports domain types, and both @myapp/server and @myapp/client depend on it.

This ensures the frontend and backend agree on data shapes at compile time. Tools like npm workspaces, Yarn workspaces, or pnpm workspaces make this seamless.

We won't dive into monorepo tooling here, but know that TypeScript's type system scales to multi-package architectures.


Multiple Choice: Dependency Direction

QuestionSelect one

In a layered architecture, which layer should the domain layer depend on?

The application layer

Nothing (it's pure)

The infrastructure layer


Multiple Choice: Dependency Inversion

QuestionSelect one

What does "dependency inversion" mean in this context?

The application layer is at the bottom

The application depends on an interface, not a concrete implementation

The domain depends on the infrastructure


Summary

You've learned how to:

  • Structure code into layers (domain, application, infrastructure).
  • Keep the domain layer pure and free of dependencies.
  • Use interfaces (ports) to define contracts in the application layer.
  • Provide concrete implementations (adapters) in the infrastructure layer.
  • Apply dependency inversion to decouple layers.
  • Use barrel files to control what's exported.
  • Enforce invariants with branded types and smart constructors.

Layered architecture makes large systems comprehensible. Each layer has a single responsibility, and dependencies flow in one direction. TypeScript's type system enforces these boundaries at compile time, catching violations before they become runtime bugs.


Next: Static Analysis in Action — exploring what the TypeScript compiler actually proves for you.

On this page