Modellare il modello come ingegneri modelli
- Marco Montorsi
- Ingegneria del software
- 12 Nov, 2024
Nel Domain-Driven Design (DDD), un Domain Model è una rappresentazione completa e focalizzata del dominio dell’applicazione. In un Domain Model ben strutturato, la logica di business viene gestita tramite componenti specifici come value objects, entità, aggregati. Lo scopo finale è ridurre la complessità della logica di business e garantire che il codice rifletta accuratamente le regole del dominio. In questo articolo, esploreremo i principali elementi di un Domain Model in DDD e vedremo come questi si integrano per mantenere la coerenza e semplificare la gestione di una logica di dominio complessa.
Costruire il Modello: Value Objects, Entità e Aggregati
Il Domain Model è composto da alcuni elementi centrali, ognuno dei quali ha un ruolo specifico e viene utilizzato per mantenere l’integrità della logica di business.
1. Value Objects
I Value Objects rappresentano concetti che si identificano unicamente attraverso i loro valori, senza un’identità propria. Ad esempio, un colore può essere definito dai valori di rosso, verde e blu, come illustrato nel seguente esempio:
public class Color {
public readonly byte Red;
public readonly byte Green;
public readonly byte Blue;
public Color(byte red, byte green, byte blue) {
this.Red = red;
this.Green = green;
this.Blue = blue;
}
}
I Value Objects sono immutabili: ogni modifica ai valori comporta la creazione di una nuova istanza dell’oggetto. Ad esempio, se vogliamo cambiare il colore, possiamo utilizzare un metodo come MixWith
per creare un nuovo colore combinando i valori RGB di due colori esistenti:
public Color MixWith(Color other) {
return new Color(
(byte)Math.Min(this.Red + other.Red, 255),
(byte)Math.Min(this.Green + other.Green, 255),
(byte)Math.Min(this.Blue + other.Blue, 255)
);
}
Questa immutabilità garantisce che i Value Objects possano essere utilizzati senza rischi di effetti collaterali e rappresentano una scelta ideale per proprietà che descrivono le caratteristiche di un’entità.
2. Entities e Identità
A differenza dei Value Objects, le Entities rappresentano oggetti con un’identità unica nel dominio. Sono identificabili da un ID specifico che rimane costante durante il ciclo di vita dell’oggetto, mentre il loro stato può cambiare. Ad esempio, una Person
potrebbe essere un’entità con un ID univoco che ne garantisce l’identità:
public class Person {
public readonly PersonId Id;
public Name Name { get; set; }
public Person(PersonId id, Name name) {
this.Id = id;
this.Name = name;
}
}
In questo esempio, PersonId
è un Value Object che serve come identificatore unico della persona. Questo approccio permette di evitare ambiguità quando ci sono persone con nomi simili e garantisce che ogni persona sia trattata come un’istanza unica nel sistema.
3. Aggregates: Gestire la Consistenza con i Boundaries
Gli Aggregates sono un concetto fondamentale in DDD per gestire la coerenza del modello. Un Aggregato è un insieme di entità e value objects che condividono una boundary logica e devono essere trattati come un’unità per garantire la coerenza dei dati. L’aggregate root è l’entità principale dell’aggregato, ed è l’unico punto di accesso per modificarne lo stato dall’esterno. In questo modo, l’aggregato root mantiene la consistenza e applica le regole di business.
Consideriamo un esempio di aggregato basato su un sistema di ticketing, dove un Ticket
è l’aggregate root che gestisce messaggi e altre entità correlate:
public class Ticket {
private List<Message> _messages;
public void AddMessage(UserId from, string body) {
var message = new Message(from, body);
_messages.Add(message);
}
}
In questo caso, il Ticket
garantisce che l’aggiunta di messaggi rispetti le regole del dominio. Per mantenere la coerenza, ogni operazione che modifica lo stato interno, come AddMessage
, deve essere eseguita tramite l’aggregate root, che funge da boundary transazionale per tutte le entità all’interno.
Introduzione al Pattern Aggregate
Quando si affronta la progettazione di applicazioni complesse, specialmente nel contesto del Domain-Driven Design (DDD), emerge spesso la necessità di garantire la coerenza dei dati. Un pattern centrale per raggiungere questo obiettivo è l’Aggregato. Un aggregato non è solo un’entità: è un’entità che ha un campo identificativo specifico e il cui stato è soggetto a modifiche durante il ciclo di vita. L’obiettivo principale del pattern aggregate è proteggere la coerenza dei dati, definendo dei confini chiari entro i quali le modifiche possono essere eseguite. In questo articolo esploreremo il funzionamento di un aggregato, i suoi vantaggi e i meccanismi per mantenerne la consistenza.
Coerenza e Confini dell’Aggregate
La coerenza è uno degli aspetti cruciali per un aggregato. Dato che lo stato di un aggregato è modificabile, esiste il rischio che i dati diventino incoerenti. Il pattern aggregate impone una struttura di confini che separa l’aggregato stesso dal resto dell’applicazione, agendo come un “confine di coerenza”. Questo significa che tutte le modifiche allo stato dell’aggregato devono rispettare le regole di business associate.
Dal punto di vista implementativo, la coerenza viene garantita consentendo solo alla logica dell’aggregato di modificarne lo stato. Gli oggetti esterni possono solo leggere lo stato dell’aggregato; tutte le modifiche devono avvenire attraverso i metodi della sua interfaccia pubblica, chiamati comandi.
Esempio di Comando
I comandi sono metodi pubblici che permettono modifiche controllate. Ad esempio, supponiamo di avere un aggregato chiamato Ticket
. Per aggiungere un messaggio a un ticket, potremmo esporre un comando chiamato AddMessage
:
public class Ticket
{
public void AddMessage(UserId from, string body)
{
var message = new Message(from, body);
_messages.Append(message);
}
}
Alternativamente, possiamo rappresentare il comando come un oggetto che incapsula tutti i dati necessari per l’esecuzione:
public class Ticket
{
public void Execute(AddMessage cmd)
{
var message = new Message(cmd.from, cmd.body);
_messages.Append(message);
}
}
In entrambi i casi, la logica di business per aggiungere un messaggio è confinata all’interno dell’aggregato.
Gestione della Concorrenza
Un altro aspetto critico per un aggregato è la gestione della concorrenza. In un contesto multi-threading o distribuito, più processi possono tentare di aggiornare simultaneamente lo stesso aggregato, rischiando di sovrascrivere modifiche. Per evitare conflitti, si utilizza un controllo di versione: ogni aggiornamento incrementa la versione dell’aggregato. Prima di scrivere su database, il sistema verifica che la versione corrente corrisponda a quella letta precedentemente.
Ad esempio, in SQL, il controllo di versione potrebbe essere implementato così:
UPDATE tickets
SET ticket_status = @new_status,
agg_version = agg_version + 1
WHERE ticket_id=@id AND agg_version=@expected_version;
In questo modo, se la versione letta non corrisponde, l’operazione fallisce e il processo può gestire l’errore e riprovare l’operazione. Questo approccio è applicabile anche a database NoSQL.
Confini Transazionali
L’aggregato agisce anche come un confine transazionale. Le modifiche allo stato dell’aggregato devono essere eseguite come un’unica operazione atomica: o tutte le modifiche vengono salvate oppure nessuna di esse viene applicata. Questo principio vieta le transazioni su più aggregati contemporaneamente, il che ci costringe a progettare attentamente i confini dell’aggregato per soddisfare i requisiti di business.
Gerarchia di Entità e Oggetti Valore
Un aggregato può includere al suo interno altre entità e oggetti valore, creando una gerarchia di entità. Questi oggetti non sono autonomi e non possono esistere separatamente dall’aggregato principale, poiché sono legati alle sue regole di business. In questo modo, è possibile soddisfare scenari di business complessi dove le regole dipendono dallo stato di più oggetti.
Ad esempio, si potrebbe avere una regola che richiede la riassegnazione automatica di un ticket se un agente non ha risposto entro un certo limite di tempo:
public class Ticket
{
List<Message> _messages;
public void Execute(EvaluateAutomaticActions cmd)
{
if (this.IsEscalated && this.RemainingTimePercentage < 0.5 &&
GetUnreadMessagesCount(for: AssignedAgent) > 0)
{
_agent = AssignNewAgent();
}
}
public int GetUnreadMessagesCount(UserId id)
{
return _messages.Where(x => x.To == id && !x.WasRead).Count();
}
}
In questo esempio, la verifica delle condizioni per la riassegnazione viene eseguita in modo atomico, garantendo che lo stato dell’aggregato rimanga consistente durante tutta l’operazione.
Riferimenti tra Aggregati
Ogni aggregato rappresenta un confine transazionale separato. Questo significa che non è possibile includere direttamente riferimenti ad altri aggregati, poiché violerebbe il principio di confine transazionale. Invece, le dipendenze tra aggregati vengono implementate tramite identificatori. Ad esempio, un aggregato Order
può contenere un riferimento all’ID di un Customer
piuttosto che un riferimento diretto.
Conclusione
Il pattern aggregate offre un approccio robusto per la gestione della coerenza e della complessità di dominio. Definendo dei confini chiari e imponendo regole di business rigorose, gli aggregati garantiscono che i dati siano sempre consistenti, anche in presenza di aggiornamenti concorrenti o scenari complessi. Tuttavia, il successo dell’implementazione di questo pattern dipende dalla capacità di modellare i confini transazionali e le relazioni gerarchiche delle entità in modo da rispettare le logiche di business del dominio.