Salta al contenuto principale

1 - Clean Architecture in Angular: Struttura Scalabile

Come applicare Clean Architecture in Angular: separazione dei layer, dependency rule, use cases e repository pattern per progetti scalabili.

Cosa ne pensi di questo articolo?

1 - Clean Architecture in Angular: Struttura Scalabile
Architettura SoftwareFoto:Ruben BoekelooPexels

In Breve

Come applicare Clean Architecture in Angular: separazione dei layer, dependency rule, use cases e repository pattern per progetti scalabili.

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.

Struttura cartelle - Clean Architecture Angular

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 Model - Order Entity

// 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à.

Value Object - OrderItem

// 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.

Repository Interface - Porta del Domain

// 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

Use Case - Creazione Ordine

// 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.

DTO e Mapper

// 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).

Implementazione HTTP del Repository

// 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.

Provider Configuration

// 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.

Componente Presentation - Order List

// 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.

Unit Test - CreateOrderUseCase

// 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.

Fonti e approfondimenti

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti