Clean Architecture in Angular: Struttura Scalabile e Manutenibile
La Clean Architecture, proposta da Robert C. Martin (Uncle Bob), definisce un modello di organizzazione del codice in cui le regole di business sono isolate dai dettagli implementativi. Applicata ad Angular, questa architettura consente di costruire applicazioni che resistono al tempo, facilitano il testing e permettono di sostituire framework, librerie e infrastrutture senza riscrivere la logica di dominio.
In questo articolo vedremo come strutturare un progetto Angular seguendo i principi della Clean Architecture, con layer separati per Domain, Application, Infrastructure e Presentation. Affronteremo la dependency rule, l'inversione delle dipendenze e pattern concreti come Use Cases, Repository e Mapper.
Cosa Imparerai
- I principi fondamentali della Clean Architecture
- Come organizzare le cartelle in un progetto Angular
- Il pattern Use Case / Interactor
- Repository Pattern per l'accesso ai dati
- Mapping tra Domain Model e DTO
- Dependency Injection e inversione delle dipendenze
- Applicazione pratica dei principi SOLID
Principi Fondamentali
La Clean Architecture si basa su un concetto centrale: le dipendenze puntano sempre verso l'interno. I layer esterni (UI, database, API) dipendono dai layer interni (logica di business, entità di dominio), mai il contrario. Questo principio, noto come Dependency Rule, garantisce che il cuore dell'applicazione resti indipendente da qualsiasi tecnologia specifica.
I Quattro Layer
| Layer | Responsabilità | Esempi in Angular |
|---|---|---|
| Domain | Entità, Value Objects, interfacce Repository | Classi pure TypeScript, nessuna dipendenza Angular |
| Application | Use Cases, orchestrazione della logica | Servizi che coordinano operazioni di dominio |
| Infrastructure | Implementazioni concrete, accesso dati, API | HttpClient, localStorage, Firebase, adapter |
| Presentation | UI, componenti, gestione dello stato visivo | Componenti Angular, template, pipe |
Struttura delle Cartelle
La struttura delle cartelle riflette direttamente i layer architetturali. Ogni feature dell'applicazione viene organizzata seguendo lo stesso schema, rendendo il progetto prevedibile e navigabile anche per sviluppatori che si uniscono al team in un secondo momento.
src/
app/
core/ # Servizi singleton, guard, interceptor
interceptors/
guards/
features/
orders/ # Feature: Gestione Ordini
domain/
models/
order.model.ts # Entità di dominio
order-item.model.ts # Value Object
repositories/
order.repository.ts # Interfaccia (porta)
application/
use-cases/
create-order.use-case.ts
get-orders.use-case.ts
cancel-order.use-case.ts
dto/
create-order.dto.ts
order-response.dto.ts
mappers/
order.mapper.ts
infrastructure/
repositories/
order-http.repository.ts # Implementazione HTTP
order-mock.repository.ts # Implementazione mock
adapters/
order-api.adapter.ts
presentation/
pages/
order-list/
order-detail/
components/
order-card/
order-form/
state/
orders.store.ts
shared/ # Componenti e utility condivisi
ui/
pipes/
directives/
Il Layer Domain
Il layer Domain contiene le entità di business e le interfacce che definiscono i contratti per l'accesso ai dati. Questo layer non ha alcuna dipendenza da Angular, RxJS o qualsiasi libreria esterna. Sono classi e interfacce TypeScript pure.
Entità di Dominio
Le entità rappresentano i concetti centrali del business. Contengono regole di validazione e logica intrinseca al dominio. Non sono semplici contenitori di dati (anemic model), ma oggetti ricchi di comportamento.
// domain/models/order.model.ts
export type OrderStatus = 'draft' | 'confirmed' | 'shipped' | 'delivered' | 'cancelled';
export class Order {
constructor(
public readonly id: string,
public readonly customerId: string,
private _items: OrderItem[],
private _status: OrderStatus = 'draft',
public readonly createdAt: Date = new Date()
) {
if (_items.length === 0) {
throw new Error('Un ordine deve contenere almeno un articolo');
}
}
get status(): OrderStatus {
return this._status;
}
get items(): ReadonlyArray<OrderItem> {
return [...this._items];
}
get total(): number {
return this._items.reduce(
(sum, item) => sum + item.subtotal, 0
);
}
confirm(): void {
if (this._status !== 'draft') {
throw new Error('Solo gli ordini in bozza possono essere confermati');
}
this._status = 'confirmed';
}
cancel(): void {
if (this._status === 'shipped' || this._status === 'delivered') {
throw new Error('Non è possibile cancellare un ordine già spedito');
}
this._status = 'cancelled';
}
addItem(item: OrderItem): void {
const existing = this._items.find(i => i.productId === item.productId);
if (existing) {
throw new Error('Prodotto già presente. Aggiorna la quantità.');
}
this._items.push(item);
}
}
Value Objects
I Value Objects sono oggetti immutabili definiti dal loro valore, non da un'identità. Due Value Objects con gli stessi attributi sono considerati uguali. Sono ideali per rappresentare concetti come prezzo, indirizzo, quantità.
// domain/models/order-item.model.ts
export class OrderItem {
public readonly subtotal: number;
constructor(
public readonly productId: string,
public readonly productName: string,
public readonly quantity: number,
public readonly unitPrice: number
) {
if (quantity <= 0) {
throw new Error('La quantità deve essere maggiore di zero');
}
if (unitPrice < 0) {
throw new Error('Il prezzo unitario non può essere negativo');
}
this.subtotal = quantity * unitPrice;
}
equals(other: OrderItem): boolean {
return this.productId === other.productId
&& this.quantity === other.quantity
&& this.unitPrice === other.unitPrice;
}
}
Interfaccia Repository (Porta)
L'interfaccia del repository definisce il contratto per l'accesso ai dati. Risiede nel layer Domain perchè rappresenta ciò di cui il dominio ha bisogno, senza specificare come i dati vengono ottenuti. Questa è l'essenza dell'inversione delle dipendenze.
// domain/repositories/order.repository.ts
import { Observable } from 'rxjs';
import { Order } from '../models/order.model';
export abstract class OrderRepository {
abstract getAll(): Observable<Order[]>;
abstract getById(id: string): Observable<Order>;
abstract getByCustomer(customerId: string): Observable<Order[]>;
abstract save(order: Order): Observable<Order>;
abstract delete(id: string): Observable<void>;
}
perchè una classe astratta e non un'interfaccia?
In Angular, il sistema di Dependency Injection richiede un token a runtime. Le interfacce TypeScript vengono rimosse in fase di compilazione e non possono essere usate come token DI. Le classi astratte, invece, esistono a runtime e fungono sia da contratto che da token per l'injection.
Il Layer Application: Use Cases
Il layer Application contiene i casi d'uso (o interactor), che orchestrano la logica di business coordinando entità di dominio e repository. Ogni use case rappresenta una singola operazione che l'applicazione può eseguire. Questo layer dipende dal Domain ma non conosce i dettagli di Infrastructure o Presentation.
Struttura di un Use Case
// application/use-cases/create-order.use-case.ts
import { Injectable } from '@angular/core';
import { Observable, map } from 'rxjs';
import { Order } from '../../domain/models/order.model';
import { OrderItem } from '../../domain/models/order-item.model';
import { OrderRepository } from '../../domain/repositories/order.repository';
import { CreateOrderDto } from '../dto/create-order.dto';
import { OrderResponseDto } from '../dto/order-response.dto';
import { OrderMapper } from '../mappers/order.mapper';
@Injectable({ providedIn: 'root' })
export class CreateOrderUseCase {
constructor(private readonly orderRepo: OrderRepository) {}
execute(dto: CreateOrderDto): Observable<OrderResponseDto> {
const items = dto.items.map(
i => new OrderItem(i.productId, i.productName, i.quantity, i.unitPrice)
);
const order = new Order(
crypto.randomUUID(),
dto.customerId,
items
);
return this.orderRepo.save(order).pipe(
map(saved => OrderMapper.toResponse(saved))
);
}
}
DTO e Mapper
I Data Transfer Object (DTO) definiscono la forma dei dati in ingresso e in uscita dal layer Application. I Mapper convertono tra entità di dominio e DTO, mantenendo separati i due mondi.
// application/dto/create-order.dto.ts
export interface CreateOrderDto {
customerId: string;
items: {
productId: string;
productName: string;
quantity: number;
unitPrice: number;
}[];
}
// application/dto/order-response.dto.ts
export interface OrderResponseDto {
id: string;
customerId: string;
status: string;
items: {
productId: string;
productName: string;
quantity: number;
unitPrice: number;
subtotal: number;
}[];
total: number;
createdAt: string;
}
// application/mappers/order.mapper.ts
import { Order } from '../../domain/models/order.model';
import { OrderResponseDto } from '../dto/order-response.dto';
export class OrderMapper {
static toResponse(order: Order): OrderResponseDto {
return {
id: order.id,
customerId: order.customerId,
status: order.status,
items: order.items.map(item => ({
productId: item.productId,
productName: item.productName,
quantity: item.quantity,
unitPrice: item.unitPrice,
subtotal: item.subtotal,
})),
total: order.total,
createdAt: order.createdAt.toISOString(),
};
}
}
Il Layer Infrastructure
Il layer Infrastructure fornisce le implementazioni concrete delle interfacce definite nel Domain. Qui risiedono le chiamate HTTP, la persistenza su localStorage, l'integrazione con Firebase e qualsiasi altro dettaglio tecnologico. Questo layer dipende sia dal Domain (per implementare le interfacce) sia da librerie esterne (HttpClient, Firebase SDK).
// infrastructure/repositories/order-http.repository.ts
import { Injectable } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, map } from 'rxjs';
import { Order } from '../../domain/models/order.model';
import { OrderItem } from '../../domain/models/order-item.model';
import { OrderRepository } from '../../domain/repositories/order.repository';
interface OrderApiResponse {
id: string;
customer_id: string;
status: string;
items: {
product_id: string;
product_name: string;
qty: number;
price: number;
}[];
created_at: string;
}
@Injectable()
export class OrderHttpRepository extends OrderRepository {
private readonly baseUrl = '/api/orders';
constructor(private readonly http: HttpClient) {
super();
}
getAll(): Observable<Order[]> {
return this.http.get<OrderApiResponse[]>(this.baseUrl).pipe(
map(responses => responses.map(r => this.toDomain(r)))
);
}
getById(id: string): Observable<Order> {
return this.http.get<OrderApiResponse>(
`${this.baseUrl}/${id}`
).pipe(
map(r => this.toDomain(r))
);
}
getByCustomer(customerId: string): Observable<Order[]> {
return this.http.get<OrderApiResponse[]>(
`${this.baseUrl}?customer=${customerId}`
).pipe(
map(responses => responses.map(r => this.toDomain(r)))
);
}
save(order: Order): Observable<Order> {
const payload = this.toApi(order);
return this.http.post<OrderApiResponse>(this.baseUrl, payload).pipe(
map(r => this.toDomain(r))
);
}
delete(id: string): Observable<void> {
return this.http.delete<void>(`${this.baseUrl}/${id}`);
}
private toDomain(response: OrderApiResponse): Order {
const items = response.items.map(
i => new OrderItem(i.product_id, i.product_name, i.qty, i.price)
);
return new Order(
response.id,
response.customer_id,
items,
response.status as any,
new Date(response.created_at)
);
}
private toApi(order: Order): object {
return {
customer_id: order.customerId,
status: order.status,
items: order.items.map(i => ({
product_id: i.productId,
product_name: i.productName,
qty: i.quantity,
price: i.unitPrice,
})),
};
}
}
Configurazione della Dependency Injection
Il collegamento tra interfaccia e implementazione avviene tramite il sistema DI di Angular. Nella configurazione dei provider, specifichiamo quale implementazione concreta deve essere utilizzata quando viene richiesta la classe astratta del repository.
// app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { OrderRepository } from './features/orders/domain/repositories/order.repository';
import { OrderHttpRepository } from './features/orders/infrastructure/repositories/order-http.repository';
export const appConfig: ApplicationConfig = {
providers: [
provideHttpClient(),
{
provide: OrderRepository,
useClass: OrderHttpRepository,
},
],
};
Vantaggio: Sostituibilita
Cambiando una sola riga di configurazione, puoi passare da OrderHttpRepository
a OrderMockRepository per i test, oppure a OrderFirebaseRepository
se decidi di migrare il backend. Il resto dell'applicazione non cambia.
Il Layer Presentation
Il layer Presentation comprende i componenti Angular, i template e la gestione dello stato visivo. I componenti interagiscono esclusivamente con i Use Cases del layer Application, senza conoscere i dettagli di dominio o infrastruttura.
// presentation/pages/order-list/order-list.component.ts
import { Component, OnInit, signal } from '@angular/core';
import { GetOrdersUseCase } from '../../../application/use-cases/get-orders.use-case';
import { OrderResponseDto } from '../../../application/dto/order-response.dto';
@Component({
selector: 'app-order-list',
standalone: true,
templateUrl: './order-list.component.html',
styleUrls: ['./order-list.component.css'],
})
export class OrderListComponent implements OnInit {
orders = signal<OrderResponseDto[]>([]);
loading = signal(true);
error = signal<string | null>(null);
constructor(private readonly getOrders: GetOrdersUseCase) {}
ngOnInit(): void {
this.getOrders.execute().subscribe({
next: (data) => {
this.orders.set(data);
this.loading.set(false);
},
error: (err) => {
this.error.set('Errore nel caricamento degli ordini');
this.loading.set(false);
},
});
}
}
Principi SOLID in Pratica
La Clean Architecture è il veicolo naturale per applicare i principi SOLID in un progetto Angular. Vediamo come ciascun principio si manifesta nella struttura che abbiamo definito.
| Principio | Applicazione nella Clean Architecture |
|---|---|
| Single Responsibility | Ogni Use Case ha una sola responsabilità. Ogni layer ha un ruolo ben definito. |
| Open/Closed | Nuove implementazioni del Repository (es. Firebase) senza modificare il codice esistente. |
| Liskov Substitution | Qualsiasi implementazione del Repository può sostituire quella attuale senza effetti collaterali. |
| Interface Segregation | I DTO espongono solo i dati necessari. Le interfacce dei repository sono specifiche per feature. |
| Dependency Inversion | I layer alti (Use Case) dipendono da astrazioni (Repository astratto), non da implementazioni concrete. |
Testing nella Clean Architecture
Uno dei vantaggi principali della Clean Architecture e la testabilità. Ogni layer può essere testato in isolamento grazie alla separazione delle dipendenze.
// application/use-cases/create-order.use-case.spec.ts
import { of } from 'rxjs';
import { CreateOrderUseCase } from './create-order.use-case';
import { OrderRepository } from '../../domain/repositories/order.repository';
import { Order } from '../../domain/models/order.model';
import { OrderItem } from '../../domain/models/order-item.model';
describe('CreateOrderUseCase', () => {
let useCase: CreateOrderUseCase;
let mockRepo: jasmine.SpyObj<OrderRepository>;
beforeEach(() => {
mockRepo = jasmine.createSpyObj('OrderRepository', ['save']);
useCase = new CreateOrderUseCase(mockRepo);
});
it('should create an order and return a response DTO', (done) => {
const dto = {
customerId: 'cust-1',
items: [{
productId: 'prod-1',
productName: 'Widget',
quantity: 2,
unitPrice: 25.00,
}],
};
const savedOrder = new Order(
'order-123', 'cust-1',
[new OrderItem('prod-1', 'Widget', 2, 25.00)]
);
mockRepo.save.and.returnValue(of(savedOrder));
useCase.execute(dto).subscribe(result => {
expect(result.id).toBe('order-123');
expect(result.total).toBe(50);
expect(result.items.length).toBe(1);
expect(mockRepo.save).toHaveBeenCalledTimes(1);
done();
});
});
});
Errori Comuni da Evitare
L'adozione della Clean Architecture richiede disciplina. Ecco gli errori più frequenti che compromettono i benefici dell'architettura.
| Errore | Conseguenza | Soluzione |
|---|---|---|
| Usare HttpClient direttamente nei componenti | Accoppiamento tra UI e infrastruttura | Passare sempre attraverso Use Cases e Repository |
| Entità di dominio anemiche (solo dati, nessuna logica) | La logica di business finisce nei servizi, difficile da testare | Arricchire le entità con validazioni e comportamenti |
| DTO che corrispondono 1:1 alle entità | Cambiare il dominio impatta direttamente l'API | Mantenere DTO e modelli indipendenti, usare Mapper |
| Dipendenze circolari tra layer | Violazione della Dependency Rule, codice fragile | Verificare che le dipendenze puntino sempre verso il Domain |
| Over-engineering per applicazioni semplici | Complessità non giustificata, sviluppo rallentato | Valutare la complessità del progetto prima di adottare la Clean Architecture |
Quando Adottare la Clean Architecture
La Clean Architecture non è la soluzione a ogni problema. La sua adozione ha un costo in termini di complessità iniziale e quantità di codice. Ecco una guida pratica per decidere quando ha senso utilizzarla.
Valutazione Rapida
| Scenario | Clean Architecture? | Alternativa |
|---|---|---|
| Prototipo / MVP | No | Struttura semplice feature-based |
| Applicazione CRUD semplice | Probabilmente no | Layered architecture (MVC) |
| Applicazione enterprise con logica complessa | Si | - |
| Team grande, lunga manutenzione prevista | Si | - |
| Possibile cambio di backend o tecnologie | Si | - |
Conclusione
La Clean Architecture applicata ad Angular trasforma il modo in cui strutturiamo le applicazioni frontend. Separando il dominio dalla tecnologia, otteniamo codice più testabile, manutenibile e resistente ai cambiamenti. La Dependency Rule garantisce che il cuore dell'applicazione resti stabile anche quando cambiamo framework, librerie o servizi esterni.
Il costo iniziale in termini di boilerplate è compensato dalla facilità di manutenzione a lungo termine, dalla chiarezza della struttura e dalla possibilità di far lavorare team diversi su layer indipendenti. Come ogni scelta architetturale, va calibrata sulla complessità reale del progetto: per applicazioni enterprise con logica di business significativa, la Clean Architecture è un investimento che ripaga nel tempo.
Domande frequenti
Quali sono i principi fondamentali della Clean Architecture?
La Clean Architecture si basa su un concetto centrale: le dipendenze puntano sempre verso l'interno. I layer esterni (UI, database, API) dipendono dai layer interni (logica di business, entità di dominio), mai il contrario.
Come viene strutturato un progetto Angular seguendo i principi della Clean Architecture?
Un progetto Angular strutturato secondo la Clean Architecture prevede quattro layer separati: Domain, Application, Infrastructure e Presentation. Ogni feature dell'applicazione viene organizzata seguendo lo stesso schema.
Qual è il ruolo del Layer Domain nella Clean Architecture?
Il Layer Domain contiene le entità di business e le interfacce che definiscono i contratti per l'accesso ai dati. Questo layer non ha alcuna dipendenza da Angular, RxJS o qualsiasi libreria esterna.
Come si applicano i principi SOLID nella Clean Architecture?
La Clean Architecture è il veicolo naturale per applicare i principi SOLID in un progetto Angular. Vediamo come ciascun principio si manifesta nella struttura che abbiamo definito.

Commenti
Caricamento commenti...
Accedi per commentare