Hexagonal architecture (or ports & adapters) starts from a simple idea: the application's rules should not need to know where the data comes from or how it is shown. The domain sits in the center, written in plain TypeScript. Around it are the use cases, which talk to the outside world only through ports, that is, contracts. At the edge are the adapters: HTTP, localStorage, Angular components. You can swap them without touching the center.
In Angular the idea fits DI very well: an abstract class is both the type of the port and the injection token. { provide: TaskRepository, useClass: HttpTaskRepository } is all the "wiring" you need.
HttpClient. And the business logic no longer spreads through components, where it is hard to find and hard to reuse.The layers and the dependency rule
There are four layers, and imports only point inward. The domain imports nothing from outside itself. Application knows the domain and the ports, but not the adapters. Adapters implement the ports. The only place that binds a port to a concrete adapter is the composition root: app.config.ts, or the providers of a route.
┌──────────────────────── outside: adapters ──────────────────────────┐
│ presentation (driving) infrastructure (driven) │
user ────────▶ │ TasksPage, TaskItem HttpTaskRepository, TaskDto │ ──▶ server
│ │ ┌──────────── application ─────────────┐ ▲ │
│ └──▶│ TaskBoard / AddTask ──▶ TaskRepository│───┘ implements │
│ │ │ ┌──── domain ────┐ │ │
│ │ └─────▶│ Task, createTask│ │ │
│ │ └─────────────────┘ │ │
│ └───────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
Rule: import arrows point ONLY inward. The domain imports nothing from outside.
app.config.ts (composition root) is the only place that binds TaskRepository to an adapter.| Layer | Contains | May import |
|---|---|---|
| domain | entities, rules, domain errors | nothing (only TypeScript) |
| application | ports (abstract classes), use cases, state for the UI | domain, @angular/core (DI, signals) |
| infrastructure | adapters: HTTP, storage, DTOs, mapping | application (the ports), domain, HttpClient |
| presentation | smart and dumb components | application, domain (types) — never infrastructure |
1. Pick the file that does the importing.
Domain: pure TypeScript
The domain holds the entities and the rules: "a title has at least 3 characters", "two open tasks cannot share the same title". It imports neither Angular, RxJS nor browser APIs. Even the clock and id generation come in as parameters, so the rules stay deterministic. The main gain: the domain is tested with plain functions, without TestBed and without mocks.
// tasks/domain/task.ts — pure TypeScript: no import from Angular, RxJS or the browser
export type TaskId = string & { readonly __brand: 'TaskId' };
export interface Task {
readonly id: TaskId;
readonly title: string;
readonly done: boolean;
readonly createdAt: Date;
}
export class DomainError extends Error {
override readonly name = 'DomainError';
}
export function normalizeTitle(raw: string): string {
return raw.trim().replace(/\s+/g, ' ');
}
// `now` and `id` come in as parameters: the domain neither reads the clock nor generates ids on its own
export function createTask(raw: string, existing: readonly Task[], now: Date, id: TaskId): Task {
const title = normalizeTitle(raw);
if (title.length < 3) throw new DomainError('The title needs at least 3 characters.');
if (existing.some((t) => !t.done && t.title.toLowerCase() === title.toLowerCase())) {
throw new DomainError('There is already an open task with this title.');
}
return { id, title, done: false, createdAt: now };
}
export function toggleTask(task: Task): Task {
return { ...task, done: !task.done };
}Ports: abstract classes as tokens
A port describes what the application needs ("list, save, delete"), not how. You write it as an abstract class: it exists at runtime, so inject(TaskRepository) works directly. An interface would disappear at compile time, and you would need a separate InjectionToken (see InjectionToken). Errors are part of the contract: adapters translate their technical errors (timeout, 503) into RepositoryUnavailableError, so the UI does not depend on HttpErrorResponse.
// tasks/application/task-repository.ts — the PORT
import { Task, TaskId } from '../domain/task';
// Abstract class, not an interface: it exists at runtime, so it is a DI token as-is.
// It has no @Injectable and knows of no implementation.
export abstract class TaskRepository {
abstract list(): Promise<readonly Task[]>;
abstract save(task: Task): Promise<void>; // upsert
abstract remove(id: TaskId): Promise<void>;
}
// Part of the contract: adapters translate their technical errors into this.
export class RepositoryUnavailableError extends Error {
override readonly name = 'RepositoryUnavailableError';
}Use cases
A use case orchestrates: it reads through a port, applies the domain rules, and saves through a port. In Angular it is an ordinary service that injects the port, not the adapter. For the UI, a "board" that holds the state in signals (tasks, error, open) is convenient. Components only read it, exactly as in state management.
// tasks/application/add-task.ts — one use case = a class with a single public method
import { Injectable, inject } from '@angular/core';
import { Task, TaskId, createTask } from '../domain/task';
import { TaskRepository } from './task-repository';
@Injectable({ providedIn: 'root' })
export class AddTask {
private readonly repo = inject(TaskRepository); // the port — does not know which adapter answers
async execute(title: string): Promise<Task> {
const existing = await this.repo.list();
const task = createTask(title, existing, new Date(), crypto.randomUUID() as TaskId); // the rule: in the domain
await this.repo.save(task); // I/O: through the port
return task;
}
}Adapters
An adapter extends TaskRepository and does all the I/O: HTTP, localStorage, memory. The DTO → domain mapping lives here too (is_done: 1 becomes done: true, the ISO string becomes a Date; see Interfaces & models). The server's format does not get past the adapter.
// tasks/infrastructure/http-task-repository.ts — the HTTP ADAPTER
import { HttpClient, HttpErrorResponse } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, firstValueFrom } from 'rxjs';
import { Task, TaskId } from '../domain/task';
import { RepositoryUnavailableError, TaskRepository } from '../application/task-repository';
import { TaskDto, taskFromDto, taskToDto } from './task-dto';
@Injectable()
export class HttpTaskRepository extends TaskRepository {
private readonly http = inject(HttpClient);
private readonly url = '/api/tasks';
async list(): Promise<readonly Task[]> {
const dtos = await this.call(this.http.get<TaskDto[]>(this.url));
return dtos.map(taskFromDto); // DTO → domain, here and nowhere else
}
async save(task: Task): Promise<void> {
await this.call(this.http.put<TaskDto>(`${this.url}/${task.id}`, taskToDto(task)));
}
async remove(id: TaskId): Promise<void> {
await this.call(this.http.delete<void>(`${this.url}/${id}`));
}
// HttpErrorResponse does not leave the adapter: the core receives the port's error
private async call<T>(request: Observable<T>): Promise<T> {
try {
return await firstValueFrom(request);
} catch (e) {
if (e instanceof HttpErrorResponse) throw new RepositoryUnavailableError(`HTTP ${e.status}`);
throw e;
}
}
}{ provide: TaskRepository, useClass: InMemoryTaskRepository } — A Map in memory — the data disappears when the injector is destroyed.
TasksPanel → TaskBoard → TaskRepository- No tasks.
- 11:35.5DI TaskRepository → InMemoryTaskRepository
- 11:35.5InMemoryTaskRepository list() → 0 (Map)
Composition root: where you choose the adapter
The port → adapter binding sits in a single place. Globally, in app.config.ts. Locally, in the providers of a route, where the adapter lives as long as the route (see Providers & the injector hierarchy). To keep the feature's details private, it exposes a provideTasks() function. The composition root chooses only "http" or "local".
// app.config.ts — COMPOSITION ROOT: the only file that knows which adapter we use
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
{ provide: TaskRepository, useClass: HttpTaskRepository },
],
};Testing: this is where the gain shows
You have three levels of tests, each one cheap:
- the domain, with pure functions;
- the use cases, with the in-memory adapter in place of HTTP;
- the adapters, with a contract test: the same suite run on every implementation of the port.
The contract test catches exactly the bugs that would otherwise slip through: an adapter that returns DTOs instead of entities, or one that duplicates instead of updating.
// Use-case test: the in-memory adapter instead of HTTP
import { TestBed } from '@angular/core/testing';
describe('AddTask', () => {
let addTask: AddTask;
let repo: InMemoryTaskRepository;
beforeEach(() => {
TestBed.configureTestingModule({
providers: [{ provide: TaskRepository, useClass: InMemoryTaskRepository }],
});
addTask = TestBed.inject(AddTask);
repo = TestBed.inject(TaskRepository) as InMemoryTaskRepository;
});
it('saves through the port', async () => {
await addTask.execute('Write tests');
expect((await repo.list()).map((t) => t.title)).toEqual(['Write tests']);
});
it('saves nothing if a domain rule is broken', async () => {
await expect(addTask.execute('ab')).rejects.toThrow(DomainError);
expect(await repo.list()).toEqual([]);
});
});| Test (same for all) | In-memory | localStorage | HTTP | HTTP without mapping |
|---|---|---|---|---|
| a new repository is empty | · | · | · | · |
| save() then list() returns the task | · | · | · | · |
| save() with the same id updates, does not duplicate | · | · | · | · |
| remove() deletes only the requested task | · | · | · | · |
| done is a boolean, createdAt is a Date | · | · | · | · |
| returns domain entities, not DTOs | · | · | · | · |
How to enforce the rule in a team
An architecture rule kept only in your head does not survive the first deadline. Put it in lint. With ESLint's no-restricted-imports, split by layer, any wrong import fails in CI. In an Nx monorepo you do the same with tags and @nx/enforce-module-boundaries (see Monorepo, libraries & boundaries and Barrels, aliases & boundaries).
// The dependency rule, checked on every commit (ESLint no-restricted-imports with regex)
export default [
{
files: ['src/app/features/*/domain/**/*.ts'],
rules: {
'no-restricted-imports': ['error', { patterns: [
{ regex: '^(@angular/|rxjs)', message: 'The domain is pure TypeScript: no Angular, no RxJS.' },
{ regex: '/(application|infrastructure|ui)/', message: 'The domain does not depend on the outer layers.' },
] }],
},
},
{
files: ['src/app/features/*/application/**/*.ts'],
rules: {
'no-restricted-imports': ['error', { patterns: [
{ regex: '/(infrastructure|ui)/', message: 'Application depends on ports, not on adapters or UI.' },
{ regex: '^@angular/common/http', message: 'HTTP only in infrastructure.' },
] }],
},
},
{
files: ['src/app/features/*/ui/**/*.ts'],
rules: {
'no-restricted-imports': ['error', { patterns: [
{ regex: '/infrastructure/', message: 'The UI injects the port or the use case, not the adapter.' },
] }],
},
},
];
// In an Nx monorepo: domain/application/infrastructure as separate libraries, with tags
// and @nx/enforce-module-boundaries (see the Monorepo page).When YES, when NO
- Real business rules (prices, validations, flows) that must be tested and reused.
- An unstable or changing backend: a new API, a REST → GraphQL migration, offline mode.
- The same logic over several data sources (server, local cache, a mock for demos).
- Large, long-lived applications with several teams on the same feature.
- CRUD screens that only display what the server returns. A service with
httpResourceis enough. - Prototypes and small apps: four layers for two forms slow the team down.
- When the "port" has a single implementation and you won't test it separately. The abstraction adds nothing.
- Pure UI logic (hover, expand): it stays in the component.
A pragmatic variant works in most projects: a pure domain for the rules that matter, a port only where you have, or will have, several implementations, and the rest in the usual data-access / ui structure (see Folder structure).
Common pitfalls
@Injectable({ providedIn: 'root', useClass: HttpTaskRepository }) on the abstract class looks convenient, but the port ends up depending on infrastructure, and HttpClient lands in the bundle even where you don't use it. The choice of adapter belongs to the composition root.// ✗ "Default implementation" placed on the port
@Injectable({ providedIn: 'root', useClass: HttpTaskRepository }) // the port imports the adapter!
export abstract class TaskRepository { /* ... */ }
// Application → infrastructure: the dependency rule is broken, and HttpClient
// ends up in the bundle even for the app that only uses localStorage.
// ✓ The port stays empty; the choice lives in the composition root
export abstract class TaskRepository { /* ... */ }
providers: [{ provide: TaskRepository, useClass: HttpTaskRepository }]- DTOs that leak into the domain. If the server's type reaches the components, any API change spreads everywhere. The mapping lives in the adapter.
- Components that inject the concrete adapter (
inject(HttpTaskRepository)). Then you have no port at all. Inject the port or the use case. - An "anemic" domain. Entities without rules, with all the logic in services: you have the layers, but not the benefit.
- Technical errors that reach the UI. An
HttpErrorResponsein a component means the UI knows it is HTTP. Translate the errors in the adapter. - A clock and ids read inside the domain (
new Date(),crypto.randomUUID()). The rules become hard to test. Pass them in as parameters.
Cheat sheet
LAYERS (imports point only inward)
domain Task, createTask() pure TypeScript — no Angular, RxJS, browser
application TaskRepository (port) abstract class = type + DI token
AddTask / TaskBoard inject(TaskRepository), signals for the UI
infrastructure HttpTaskRepository extends TaskRepository, DTO ↔ domain, errors translated
presentation TasksPage, TaskItem injects application, never infrastructure
composition app.config / route.providers { provide: TaskRepository, useClass: Http… }
RECIPES
swap the adapter another useClass in the composition root (or provideTasks('local'))
use-case test providers: [{ provide: TaskRepository, useClass: InMemoryTaskRepository }]
adapter test shared contract + HttpTestingController for the HTTP mapping
DTO only in infrastructure; the port speaks in entities
errors the adapter throws RepositoryUnavailableError, not HttpErrorResponse
WHEN NOT
CRUD without rules, small app, prototype → httpResource in the component + mapping in parse