Salta al contenuto principale

03 - Bounded Contexts e Domain Driven Design: Modularità Logica

Introduzione: DDD come Fondamento della Modularita Il Domain Driven Design (DDD) non e solo un approccio alla modellazione del software: e lo strumento...

Cosa ne pensi di questo articolo?

03 - Bounded Contexts e Domain Driven Design: Modularità Logica
ArchitectureFoto:Anastasia ShuraevaPexels

In Breve

Introduzione: DDD come Fondamento della Modularita Il Domain Driven Design (DDD) non e solo un approccio alla modellazione del software: e lo strumento...

Introduzione: DDD come Fondamento della Modularita

Il Domain Driven Design (DDD) non e solo un approccio alla modellazione del software: e lo strumento più potente per identificare i confini corretti dei moduli in un modular monolith. Senza DDD, le boundaries tra moduli diventano arbitrarie, basate su criteri tecnici piuttosto che su confini di dominio reali. Il risultato e un'architettura fragile che non rispecchia il business.

In questo articolo esploreremo i concetti fondamentali di DDD applicati alla modularizzazione: Bounded Contexts, Ubiquitous Language, Context Mapping, e Aggregate Design. Vedremo come tradurre questi concetti in confini di modulo concreti e come implementarli in codice Java/Spring Boot.

Cosa Imparerai in Questo Articolo

  • I fondamenti del DDD: entità, value objects, aggregates e bounded contexts
  • Come identificare i bounded contexts della tua applicazione
  • Ubiquitous Language: perchè ogni modulo ha il proprio vocabolario
  • Context Mapping: Anti-Corruption Layer, Shared Kernel, Customer/Supplier
  • Aggregate Design: la chiave per transazioni consistenti
  • Come tradurre i bounded contexts in struttura di package Java
  • La legge di Conway: allineare team e architettura

Fondamenti DDD per il Modular Monolith

Il Domain Driven Design introduce un vocabolario preciso per modellare il software attorno al dominio di business. Vediamo i building block fondamentali:

Entità

Un'entità e un oggetto di dominio con un'identità unica che persiste nel tempo. Due entità sono uguali se hanno lo stesso ID, indipendentemente dai valori dei loro attributi. Esempi: Order, User, Product.

Value Objects

Un value object e un oggetto immutabile definito dai suoi attributi, senza identità propria. Due value object con gli stessi attributi sono identici. Esempi: Money, Address, EmailAddress.

Aggregates

Un aggregate e un cluster di entità e value objects trattati come un'unita per le operazioni di modifica. L'aggregate ha una root entity che funge da punto di accesso e garantisce l'integrita delle invarianti interne. Le transazioni non devono mai attraversare i confini di un aggregate.


// Esempio di Aggregate: Order
// Order e la root entity dell'aggregate
// OrderItem e Money sono parte dell'aggregate

public class Order {
    private final UUID id;               // identità dell'entità
    private UUID userId;
    private List<OrderItem> items;       // entità figlie
    private Money total;                 // value object
    private OrderStatus status;
    private Instant createdAt;

    // Factory method - unico modo per creare un Order
    public static Order create(UUID userId, List<ProductDto> products) {
        Order order = new Order();
        order.id = UUID.randomUUID();
        order.userId = userId;
        order.items = products.stream()
            .map(p -> OrderItem.fromProduct(p))
            .toList();
        order.total = Money.sum(
            order.items.stream().map(OrderItem::getPrice).toList()
        );
        order.status = OrderStatus.CREATED;
        order.createdAt = Instant.now();
        return order;
    }

    // Le invarianti sono protette all'interno dell'aggregate
    public void cancel() {
        if (this.status == OrderStatus.SHIPPED) {
            throw new IllegalStateException(
                "Cannot cancel a shipped order"
            );
        }
        this.status = OrderStatus.CANCELLED;
    }
}

// Value Object immutabile
public record Money(BigDecimal amount, Currency currency) {
    public static Money sum(List<Money> values) {
        return new Money(
            values.stream()
                .map(Money::amount)
                .reduce(BigDecimal.ZERO, BigDecimal::add),
            values.get(0).currency()
        );
    }
}
    

Bounded Contexts: I Confini del Dominio

Un Bounded Context e un confine linguistico e funzionale all'interno del quale un modello di dominio e valido e consistente. All'interno di un bounded context, ogni termine ha un significato preciso e univoco. Attraverso i confini del context, lo stesso concetto può avere significati diversi.

Esempio concreto: il concetto di "Prodotto" ha significati diversi in contesti diversi:

  • Nel Catalogo: un prodotto ha nome, descrizione, immagini, categorie
  • Nel Magazzino: un prodotto ha quantità disponibile, locazione, soglia di riordino
  • Nell'Ordine: un prodotto e una riga con prezzo unitario, quantità ordinata, sconto
  • Nella Spedizione: un prodotto ha peso, dimensioni, fragilita

Forzare un unico modello Product che soddisfi tutti questi contesti crea una God Class con decine di attributi, la maggior parte dei quali irrilevanti per ciascun contesto specifico.


// SBAGLIATO: Un unico modello Product per tutti i contesti
// "God Class" che viola il principio di bounded context
public class Product {
    // Dati catalogo
    private String name;
    private String description;
    private List<String> images;
    private Category category;

    // Dati magazzino
    private int stockQuantity;
    private String warehouseLocation;
    private int reorderThreshold;

    // Dati ordine
    private BigDecimal unitPrice;
    private BigDecimal discount;

    // Dati spedizione
    private double weight;
    private Dimensions dimensions;
    private boolean fragile;
    // ... 30+ attributi
}

// CORRETTO: Un modello per ogni bounded context
// Catalogo
public class CatalogProduct {
    private UUID id;
    private String name;
    private String description;
    private List<String> images;
    private Category category;
}

// Magazzino
public class InventoryItem {
    private UUID productId;  // riferimento, non l'oggetto intero
    private int stockQuantity;
    private String location;
    private int reorderThreshold;
}

// Ordine
public class OrderLineItem {
    private UUID productId;  // riferimento
    private Money unitPrice;
    private int quantity;
    private Money discount;
}
    

Regola Fondamentale

Ogni bounded context deve avere il proprio modello per i concetti condivisi. I contesti comunicano attraverso ID di riferimento, non attraverso oggetti condivisi. Questo e il principio chiave che rende i moduli indipendenti e estraibili.

Ubiquitous Language: Un Vocabolario per Context

L'Ubiquitous Language e il vocabolario condiviso tra sviluppatori e domain expert all'interno di un bounded context. Ogni context ha il proprio linguaggio, e lo stesso termine può avere significati diversi in contesti diversi.

Questo concetto ha implicazioni pratiche sul naming nel codice:

  • Nel modulo Order: Customer e chi effettua l'ordine, con indirizzo di fatturazione e metodo di pagamento
  • Nel modulo Shipping: Customer e il destinatario, con indirizzo di consegna e preferenze di spedizione
  • Nel modulo Support: Customer e chi apre un ticket, con storico delle interazioni e livello di priorità

Forzare un unico modello Customer per tutti i contesti crea accoppiamento e impedisce ai team di evolvere i propri modelli in modo indipendente.

Context Mapping: Come i Contesti Comunicano

Il Context Mapping definisce come i bounded contexts interagiscono tra loro. Esistono diversi pattern di relazione, ciascuno con trade-off specifici:

Anti-Corruption Layer (ACL)

L'Anti-Corruption Layer e uno strato di traduzione che isola un bounded context dal modello di un altro context. Quando il modulo Order ha bisogno di dati dal modulo Catalog, non usa direttamente i DTO del Catalog: li traduce nel proprio modello interno attraverso un ACL.


// Anti-Corruption Layer nel modulo Order
// Traduce i concetti del Catalog nel linguaggio dell'Order
package com.ecommerce.order.internal.acl;

@Component
class CatalogAntiCorruptionLayer {

    private final CatalogModuleApi catalogModule;

    // Traduce ProductDto (catalogo) in OrderProduct (ordine)
    public OrderProduct resolveProduct(UUID productId) {
        ProductDto catalogProduct = catalogModule.findById(productId)
            .orElseThrow(() -> new ProductNotAvailableException(productId));

        // Traduzione: prende solo ciò che serve al contesto Order
        return new OrderProduct(
            catalogProduct.id(),
            catalogProduct.name(),
            Money.of(catalogProduct.price(), Currency.EUR),
            catalogProduct.isAvailable()
        );
    }
}

// OrderProduct: modello interno del contesto Order
// Non e il ProductDto del Catalog
record OrderProduct(
    UUID productId,
    String displayName,
    Money price,
    boolean available
) {}
    

Shared Kernel

Lo Shared Kernel e un piccolo insieme di codice condiviso tra due o più bounded contexts. Include tipicamente value objects comuni (come Money, Address), eventi di dominio condivisi, e costanti. Lo shared kernel deve essere minimale e cambiato solo con il consenso di tutti i team coinvolti.


// Shared Kernel: codice condiviso tra moduli
package com.ecommerce.shared;

// Value objects condivisi
public record Money(BigDecimal amount, Currency currency) {
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new CurrencyMismatchException();
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }
}

public record Address(
    String street, String city,
    String zipCode, String country
) {}

// Eventi di dominio condivisi
public record OrderCreatedEvent(
    UUID orderId, UUID userId, Money total, Instant timestamp
) {}
    

Customer/Supplier

Nel pattern Customer/Supplier, un context (supplier) fornisce dati o servizi a un altro context (customer). Il supplier definisce l'interfaccia, ma tiene conto delle esigenze del customer. Questo pattern si applica quando un modulo dipende chiaramente da un altro in modo unidirezionale.

Aggregate Design: Transazioni e Consistenza

Il design degli aggregate e cruciale per un modular monolith perchè definisce i confini transazionali. Una transazione ACID deve operare all'interno di un singolo aggregate. Le interazioni tra aggregate diversi (anche nello stesso modulo) devono essere gestite con eventual consistency attraverso eventi di dominio.

Regole per il Design degli Aggregates

  • Piccoli: un aggregate deve essere il più piccolo possibile. Aggrega solo ciò che deve essere consistente in una singola transazione
  • Riferimenti per ID: gli aggregate non contengono riferimenti diretti ad altri aggregate, ma solo i loro ID
  • Una transazione per aggregate: non modificare mai più aggregate nella stessa transazione
  • Eventual consistency tra aggregate: usa eventi di dominio per coordinare cambiamenti tra aggregate diversi

// Design corretto degli Aggregates
// Order Aggregate - confine transazionale
public class Order {
    private UUID id;
    private UUID userId;      // riferimento per ID, non User object
    private UUID paymentId;   // riferimento per ID, non Payment object
    private List<OrderItem> items; // parte dell'aggregate
    private OrderStatus status;

    // Operazione transazionale: avviene tutta nello stesso aggregate
    @Transactional
    public void addItem(UUID productId, int quantity, Money price) {
        // Verifica invarianti interne
        if (this.status != OrderStatus.DRAFT) {
            throw new IllegalStateException("Cannot modify confirmed order");
        }
        this.items.add(new OrderItem(productId, quantity, price));
        this.recalculateTotal();
    }
}

// SBAGLIATO: transazione che attraversa più aggregate
@Transactional
public void createOrderAndReserveStock(CreateOrderCmd cmd) {
    Order order = orderRepo.save(Order.create(cmd));
    // VIOLAZIONE: modifica un altro aggregate nella stessa transazione
    inventoryService.reserveStock(order.getItems());
    // Se reserveStock fallisce, tutto il rollback e complesso
}

// CORRETTO: eventual consistency tra aggregate
@Transactional
public void createOrder(CreateOrderCmd cmd) {
    Order order = orderRepo.save(Order.create(cmd));
    // Pubblica evento: il modulo Inventory reagira in modo asincrono
    events.publish(new OrderCreatedEvent(order.getId(), order.getItems()));
}
    

Identificare i Bounded Contexts: Metodo Pratico

Identificare i bounded contexts corretti e tanto un'arte quanto una scienza. Ecco un metodo pratico in quattro passi:

  1. Event Storming: riunisci sviluppatori e domain expert. Identifica tutti gli eventi di dominio su post-it arancioni. Raggruppa gli eventi in cluster logici: ogni cluster e un potenziale bounded context
  2. Analisi del linguaggio: identifica dove lo stesso termine ha significati diversi. Ogni divergenza linguistica indica un confine di context
  3. Analisi delle dipendenze: mappa le dipendenze tra cluster. I cluster con poche dipendenze esterne sono buoni candidati per moduli indipendenti
  4. Validazione con il team: verifica che i confini identificati rispecchino la struttura organizzativa e le competenze dei team

La Legge di Conway

La legge di Conway afferma che le organizzazioni progettano sistemi che rispecchiano la struttura della propria comunicazione. In pratica: i confini dei moduli dovrebbero allinearsi con i confini dei team. Un team responsabile del modulo Order non dovrebbe avere bisogno di coordinarsi quotidianamente con il team del modulo Shipping per completare il proprio lavoro.

Se la struttura del codice non rispecchia la struttura dei team, si creano attriti: merge conflict frequenti, riunioni di coordinamento costanti, blocchi reciproci. Allineare architettura e organizzazione e un prerequisito per il successo del modular monolith.

Prossimo Articolo

Nel prossimo articolo affronteremo il tema del database design per modular monolith: schema condiviso vs schema per modulo, data ownership, gestione delle transazioni distribuite, e pattern come Saga e Event Sourcing per la consistenza eventuale. Tradurremo i bounded contexts identificati qui in strutture dati concrete.

Domande frequenti

Qual è il principale obiettivo del Domain Driven Design (DDD) nella modularizzazione di un sistema software?

Il principale obiettivo del DDD è identificare i confini corretti dei moduli in un modulo monolitico, evitando che le boundary tra moduli siano arbitrarie e basate su criteri tecnici piuttosto che su confini di dominio reali.

Qual è la differenza principale tra entità e value objects nel contesto del DDD?

Le entità sono oggetti di dominio con un'identità unica che persiste nel tempo, mentre i value objects sono oggetti immutabili definiti dai loro attributi senza identità propria.

Come i bounded contexts interagiscono tra loro secondo il Context Mapping?

I bounded contexts possono interagire attraverso diversi pattern di relazione, come l'Anti-Corruption Layer (ACL), lo Shared Kernel e il Customer/Supplier, ognuno con trade-off specifici.

Qual è la regola principale per il design degli aggregate nel DDD?

Una transazione ACID deve operare all'interno di un singolo aggregate, e le interazioni tra aggregate diversi devono essere gestite con eventual consistency attraverso eventi di dominio.

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