Arhitectura hexagonală (sau ports & adapters) pornește de la o idee simplă: regulile aplicației nu trebuie să știe de unde vin datele și cum sunt afișate. Domeniul stă în centru, scris în TypeScript pur. În jurul lui sunt cazurile de utilizare, care vorbesc cu lumea exterioară doar prin porturi, adică contracte. La margine sunt adaptoarele: HTTP, localStorage, componente Angular. Le poți schimba fără să atingi centrul.
În Angular, ideea se potrivește foarte bine cu DI: o clasă abstractă e în același timp tipul portului și token-ul de injecție. { provide: TaskRepository, useClass: HttpTaskRepository } e tot „cablajul” de care ai nevoie.
HttpClient. Iar logica de business nu se mai împrăștie prin componente, unde e greu de găsit și de reutilizat.Straturile și regula dependenței
Sunt patru straturi, iar importurile arată doar spre interior. Domeniul nu importă nimic din afara lui. Application cunoaște domeniul și porturile, dar nu și adaptoarele. Adaptoarele implementează porturile. Singurul loc care leagă un port de un adaptor concret e composition root-ul: app.config.ts sau providers-ul unei rute.
┌──────────────────────── exterior: adaptoare ────────────────────────┐
│ presentation (driving) infrastructure (driven) │
utilizator ──▶ │ TasksPage, TaskItem HttpTaskRepository, TaskDto │ ──▶ server
│ │ ┌──────────── application ─────────────┐ ▲ │
│ └──▶│ TaskBoard / AddTask ──▶ TaskRepository│───┘ implementează│
│ │ │ ┌──── domain ────┐ │ │
│ │ └─────▶│ Task, createTask│ │ │
│ │ └─────────────────┘ │ │
│ └───────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
Regula: săgețile de import arată DOAR spre interior. Domeniul nu importă nimic din afară.
app.config.ts (composition root) e singurul loc care leagă TaskRepository de un adaptor.| Strat | Conține | Are voie să importe |
|---|---|---|
| domain | entități, reguli, erori de domeniu | nimic (doar TypeScript) |
| application | porturi (clase abstracte), cazuri de utilizare, starea pentru UI | domain, @angular/core (DI, signals) |
| infrastructure | adaptoare: HTTP, storage, DTO-uri, maparea | application (porturile), domain, HttpClient |
| presentation | componente smart și dumb | application, domain (tipuri) — niciodată infrastructure |
1. Alege fișierul care importă.
Domeniul: TypeScript pur
Domeniul conține entitățile și regulile: „un titlu are minimum 3 caractere”, „nu pot exista două sarcini deschise cu același titlu”. Nu importă Angular, RxJS sau API-uri de browser. Chiar și ceasul și generarea de id-uri vin ca parametri, ca regulile să rămână deterministe. De aici câștigul principal: domeniul se testează cu funcții simple, fără TestBed și fără mock-uri.
// tasks/domain/task.ts — TypeScript pur: niciun import din Angular, RxJS sau 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` și `id` vin ca parametri: domeniul nu citește ceasul și nu generează id-uri singur
export function createTask(raw: string, existing: readonly Task[], now: Date, id: TaskId): Task {
const title = normalizeTitle(raw);
if (title.length < 3) throw new DomainError('Titlul are nevoie de cel puțin 3 caractere.');
if (existing.some((t) => !t.done && t.title.toLowerCase() === title.toLowerCase())) {
throw new DomainError('Există deja o sarcină deschisă cu acest titlu.');
}
return { id, title, done: false, createdAt: now };
}
export function toggleTask(task: Task): Task {
return { ...task, done: !task.done };
}Porturile: clase abstracte ca token-uri
Portul descrie ce are nevoie aplicația („listează, salvează, șterge”), nu cum. Îl scrii ca clasă abstractă: există la runtime, deci inject(TaskRepository) funcționează direct. O interfață ar dispărea la compilare și ți-ar trebui un InjectionToken separat (vezi InjectionToken). Erorile fac parte din contract: adaptoarele își traduc erorile tehnice (timeout, 503) în RepositoryUnavailableError, ca UI-ul să nu depindă de HttpErrorResponse.
// tasks/application/task-repository.ts — PORTUL
import { Task, TaskId } from '../domain/task';
// Clasă abstractă, nu interfață: există la runtime, deci e direct token de DI.
// Nu are @Injectable și nu știe de nicio implementare.
export abstract class TaskRepository {
abstract list(): Promise<readonly Task[]>;
abstract save(task: Task): Promise<void>; // upsert
abstract remove(id: TaskId): Promise<void>;
}
// Parte din contract: adaptoarele își traduc erorile tehnice în asta.
export class RepositoryUnavailableError extends Error {
override readonly name = 'RepositoryUnavailableError';
}Cazurile de utilizare
Un caz de utilizare orchestrează: citește prin port, aplică regulile din domeniu, salvează prin port. În Angular e un serviciu obișnuit care injectează portul, nu adaptorul. Pentru UI e comod un „board” care ține și starea în semnale (tasks, error, open). Componentele doar îl citesc, exact ca la state management.
// tasks/application/add-task.ts — un caz de utilizare = o clasă cu o singură metodă publică
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); // portul — nu știe ce adaptor răspunde
async execute(title: string): Promise<Task> {
const existing = await this.repo.list();
const task = createTask(title, existing, new Date(), crypto.randomUUID() as TaskId); // regula: în domeniu
await this.repo.save(task); // I/O: prin port
return task;
}
}Adaptoarele
Un adaptor extends TaskRepository și face tot I/O-ul: HTTP, localStorage, memorie. Tot aici e și maparea DTO → domeniu (is_done: 1 devine done: true, string-ul ISO devine Date; vezi Interfețe & modele). Formatul serverului nu trece de adaptor.
// tasks/infrastructure/http-task-repository.ts — ADAPTORUL HTTP
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 → domeniu, aici și nicăieri altundeva
}
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 nu iese din adaptor: nucleul primește eroarea portului
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 } — Map în memorie — datele dispar când injectorul e distrus.
TasksPanel → TaskBoard → TaskRepository- Nicio sarcină.
- 11:45.8DI TaskRepository → InMemoryTaskRepository
- 11:45.8InMemoryTaskRepository list() → 0 (Map)
Composition root: unde alegi adaptorul
Legătura port → adaptor stă într-un singur loc. Global, în app.config.ts. Local, în providers-ul unei rute, unde adaptorul trăiește cât ruta (vezi Providers & ierarhia de injectori). Ca feature-ul să-și păstreze detaliile, expune o funcție provideTasks(). Composition root-ul alege doar „http” sau „local”.
// app.config.ts — COMPOSITION ROOT: singurul fișier care știe ce adaptor folosim
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(withInterceptors([authInterceptor])),
{ provide: TaskRepository, useClass: HttpTaskRepository },
],
};Testare: aici se vede câștigul
Ai trei niveluri de teste, fiecare ieftin:
- domeniul, cu funcții pure;
- cazurile de utilizare, cu adaptorul in-memory în locul HTTP-ului;
- adaptoarele, cu un test de contract, adică aceeași suită rulată pe fiecare implementare a portului.
Testul de contract prinde exact bugurile care altfel scapă: un adaptor care întoarce DTO-uri în loc de entități sau care dublează în loc să actualizeze.
// Testul cazului de utilizare: adaptorul in-memory în locul HTTP-ului
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('salvează prin port', async () => {
await addTask.execute('Scrie teste');
expect((await repo.list()).map((t) => t.title)).toEqual(['Scrie teste']);
});
it('nu salvează nimic dacă regula de domeniu e încălcată', async () => {
await expect(addTask.execute('ab')).rejects.toThrow(DomainError);
expect(await repo.list()).toEqual([]);
});
});| Test (același pentru toate) | In-memory | localStorage | HTTP | HTTP fără mapare |
|---|---|---|---|---|
| un depozit nou e gol | · | · | · | · |
| save() apoi list() întoarce sarcina | · | · | · | · |
| save() pe același id actualizează, nu dublează | · | · | · | · |
| remove() șterge doar sarcina cerută | · | · | · | · |
| done e boolean, createdAt e Date | · | · | · | · |
| întoarce entități de domeniu, nu DTO-uri | · | · | · | · |
Cum impui regula în echipă
O regulă de arhitectură ținută doar în cap nu rezistă primului deadline. Pune-o în lint. Cu no-restricted-imports din ESLint, separat pe straturi, orice import greșit pică în CI. Într-un monorepo Nx faci același lucru cu tag-uri și @nx/enforce-module-boundaries (vezi Monorepo, librării & granițe și Barrels, aliasuri & granițe).
// Regula dependenței, verificată la fiecare commit (ESLint no-restricted-imports cu regex)
export default [
{
files: ['src/app/features/*/domain/**/*.ts'],
rules: {
'no-restricted-imports': ['error', { patterns: [
{ regex: '^(@angular/|rxjs)', message: 'Domeniul e TypeScript pur: fără Angular, fără RxJS.' },
{ regex: '/(application|infrastructure|ui)/', message: 'Domeniul nu depinde de straturile exterioare.' },
] }],
},
},
{
files: ['src/app/features/*/application/**/*.ts'],
rules: {
'no-restricted-imports': ['error', { patterns: [
{ regex: '/(infrastructure|ui)/', message: 'Application depinde de porturi, nu de adaptoare sau de UI.' },
{ regex: '^@angular/common/http', message: 'HTTP doar în infrastructure.' },
] }],
},
},
{
files: ['src/app/features/*/ui/**/*.ts'],
rules: {
'no-restricted-imports': ['error', { patterns: [
{ regex: '/infrastructure/', message: 'UI-ul injectează portul sau cazul de utilizare, nu adaptorul.' },
] }],
},
},
];
// Într-un monorepo Nx: domain/application/infrastructure ca librării separate, cu tag-uri
// și @nx/enforce-module-boundaries (vezi pagina Monorepo).Când DA, când NU
- Reguli de business reale (prețuri, validări, fluxuri) care trebuie testate și reutilizate.
- Backend instabil sau în schimbare: API nou, migrare REST → GraphQL, mod offline.
- Aceeași logică pe mai multe surse de date (server, cache local, mock pentru demo-uri).
- Aplicații mari și longevive, cu mai multe echipe pe același feature.
- Ecrane CRUD care doar afișează ce vine de la server. Ajunge un serviciu cu
httpResource. - Prototipuri și aplicații mici: patru straturi pentru două formulare încetinesc echipa.
- Când „portul” are o singură implementare și nu o vei testa separat. Abstracția nu aduce nimic.
- Logică de UI pură (hover, expandat): rămâne în componentă.
O variantă pragmatică funcționează în majoritatea proiectelor: domeniu pur pentru regulile importante, un port doar acolo unde chiar ai sau vei avea mai multe implementări, iar restul rămâne în structura obișnuită data-access / ui (vezi Structura de foldere).
Capcane frecvente
@Injectable({ providedIn: 'root', useClass: HttpTaskRepository }) pe clasa abstractă pare comod, dar portul ajunge să depindă de infrastructură, iar HttpClient intră în bundle și acolo unde nu-l folosești. Alegerea adaptorului stă în composition root.// ✗ „Implementarea implicită” pusă pe port
@Injectable({ providedIn: 'root', useClass: HttpTaskRepository }) // portul importă adaptorul!
export abstract class TaskRepository { /* ... */ }
// Application → infrastructure: regula dependenței e încălcată, iar HttpClient
// ajunge în bundle chiar și pentru aplicația care folosește doar localStorage.
// ✓ Portul rămâne gol; alegerea stă în composition root
export abstract class TaskRepository { /* ... */ }
providers: [{ provide: TaskRepository, useClass: HttpTaskRepository }]- DTO-uri care scapă în domeniu. Dacă tipul de la server ajunge în componente, orice schimbare de API se propagă peste tot. Maparea stă în adaptor.
- Componente care injectează adaptorul concret (
inject(HttpTaskRepository)). Atunci n-ai port deloc. Injectează portul sau cazul de utilizare. - Domeniu „anemic”. Entități fără reguli, cu toată logica în servicii: ai straturile, dar nu și beneficiul.
- Erori tehnice care urcă până în UI.
HttpErrorResponseîn componentă înseamnă că UI-ul știe că e HTTP. Tradu erorile în adaptor. - Ceas și id-uri citite în domeniu (
new Date(),crypto.randomUUID()). Regulile devin greu de testat. Primește-le ca parametri.
Fișă rapidă
STRATURI (importurile arată doar spre interior)
domain Task, createTask() TypeScript pur — fără Angular, RxJS, browser
application TaskRepository (port) clasă abstractă = tip + token de DI
AddTask / TaskBoard inject(TaskRepository), semnale pentru UI
infrastructure HttpTaskRepository extends TaskRepository, DTO ↔ domeniu, erori traduse
presentation TasksPage, TaskItem injectează application, niciodată infrastructure
composition app.config / route.providers { provide: TaskRepository, useClass: Http… }
REȚETE
schimbi adaptorul alt useClass în composition root (sau provideTasks('local'))
test pentru use case providers: [{ provide: TaskRepository, useClass: InMemoryTaskRepository }]
test pentru adaptor contract comun + HttpTestingController pentru maparea HTTP
DTO doar în infrastructure; portul vorbește în entități
erori adaptorul aruncă RepositoryUnavailableError, nu HttpErrorResponse
CÂND NU
CRUD fără reguli, app mică, prototip → httpResource în componentă + mapare în parse