Contattaci

Rich Domain Model – Value Objects, Entities and CAP theorem

rich domain model
  • Data: 14 Giugno 2022
  • Autore: Gabriele Seroni
  • Categorie

  • Giuneco Tech

    Il nostro settore richiede uno studio continuo e una forte attenzione all’innovazione. Incentiviamo quindi dei programmi formativi annuali per tutti i componenti del team, con ore dedicate (durante l’orario di lavoro) e serate formative sia online che in presenza. Sponsorizziamo eventi, sia come partner che semplicemente come partecipanti, e scriviamo articoli su quello che abbiamo imparato per essere, a nostra volta, dei divulgatori.

    Vai alla sezione Team
  • La gestione e la riduzione della complessità sono alcune delle sfide più importanti nel mondo dello sviluppo del software. 

    Esistono molti tipi di complessità e molte definizioni ma, per semplicità, prendiamo queste 2: 

    • Domain complexity : ovvero la complessità intrinseca del dominio di riferimento ed è proporzionale al numero di variabili che è necessario tenere in considerazione (es. il dominio delle transazioni bancarie è, molto probabilmente, più complesso del dominio della registrazione degli ingressi in una palestra). 
    • Algorithmic complexity: ovvero la complessità che gli sviluppatori, gli ingegneri, gli architetti, aggiungono a quella precedente in base alle loro scelte tecnologiche, di design, di architetture che si riflettono sul codice scritto. 

    La differenza sostanziale tra le 2 tipologie di complessità sopra citate è che la prima è difficilmente riducibile poiché è determinata principalmente da fattori esterni ed al di fuori del nostro controllo: leggi sulla privacy, sulla security, agreement di high availability, di performance, ecc. 

    La seconda tipologia, al contrario, è determinata in larga parte dalla bravura, dalla capacità e dalle scelte concrete del team di sviluppo. 

    Una premessa fondamentale prima di procedere ulteriormente:
    il rapporto tra capacità del team di sviluppo e complessità inserita nel software è inversamente proporzionale! 

    Moltissimi progetti falliscono perché team di sviluppo non ben coordinati, non motivati o semplicemente non sufficientemente preparati, inseriscono troppa complessità nel software; questo fa sì che nel lungo periodo risulti non più manutenibile, non estendibile ed il progetto vada degradandosi. 

    Effettuare scelte atte a diminuire la complessità dell’applicazione e le possibilità di errore può sembrare inutilmente costoso nelle fasi iniziali del progetto, ma ha un indiscutibile beneficio nel lungo corso. 

    In questo articolo parleremo di un concetto chiamato “Rich Domain Model”, piuttosto semplice all’atto pratico da implementare (la maggiore difficoltà è comprendere il perché alcune pratiche che utilizziamo tutti i giorni in realtà siano degli anti-pattern), ma che è un fattore molto rilevante per quanto riguarda la gestione della parte di complessità sulla quale abbiamo controllo. 

     Anemic domain model 

    “È un modello che separa i dati e le operazioni che lavorano con essi l’uno dall’altro. Nella maggior parte delle volte, il tuo dominio è composto da due classi separate. Uno è l’entità, che detiene i dati, l’altro è il servizio stateless, che opera con un’entità.” 

    Probabilmente questo concetto è quello con cui la maggior parte di noi ha più familiarità e che trova implementazione nella maggior parte delle code base sulle quali abbiamo lavorato ma, per rendere il tutto più concreto, facciamo un esempio. 

    L’Entity è solitamente rappresentata con sole proprietà pubbliche, con setter e getter. Un tipico esempio in C# può essere simile a questo: 

    Il servizio è rappresentato con una classe stateless, il che significa che contiene solo metodi che richiedono sempre come parametro in input la Entity associata.  

    Un tipico servizio di questo genere in C# può essere simile al seguente: 

    Questo modello è problematico e considerato un anti-pattern principalmente per 3 fattori strettamente legati tra loro: 

    • Contraddice OOP: rispetto al concetto fondamentale dell’OOP, ovvero di combinare dati ed operazioni insieme, questa è totalmente una implementazione opposta e ricorda maggiormente un design procedurale. 
    • Duplicazione: avere logica frammentata in più classi rende meno visibili i concetti di business e questo può portare facilmente a duplicazione del codice nel corso del tempo. 
    • Mancanza di incapsulamento: la violazione di uno dei principi fondamentali dell’OOP (di cui sopra), non è una negativa di per sé, ma bensì perché ha effetti concreti che rendono peggiore in nostro codice e più prono ad errori. In quel modo è impossibile avere oggetti ben incapsulati. 

     

    Mentre la duplicazione può essere evitata utilizzando altre convenzioni o standard nella code base, la mancanza di incapsulamento ha sempre effetti dannosi sui nostri software. 

    A questo punto però è necessario fare chiarezza su quale sia il significato di incapsulamento.
    La caratteristica che viene citata maggiormente per definirlo è “information hiding”: nascondere dettagli implementativi irrilevanti agli occhi di un client. 

    C’è un secondo aspetto dell’incapsulamento che, derivando dall’information hiding, è il più grande  valore aggiunto: la protezione dell’integrità dei dati. 

    È fondamentale infatti usare l’information hiding non solo per nascondere dettagli ai client, ma soprattutto per limitare la superficie dell’API così da evitare che il nostro oggetto possa entrare in uno stato di invalidità attraverso interazioni non corrette da parte di quest’ultimo. 

    Se siete interessati agli argomenti riguardanti l’integrità del domain model, alle invarianze ed alla loro relativa gestione, vi consiglio quest’altro mio articolo: https://tech.giuneco.it/exception-vs-result/ . 

    Ritornando sull’esempio, possiamo renderci immediatamente conto che la superficie così vasta dell’API, dovuta alla frammentazione in più classi delle logiche legate al Customer ed ai getter e setter pubblici, non garantisce in nessun modo che l’Entity non entri in uno stato di invalidità: in ogni punto della nostra applicazione potremmo ritrovarci a lavorare con una istanza di Customer non valida, con dati parziali, corrotti o errati.
     

    Sperando di avere fornito gli strumenti minimi necessari per la comprensione di questa bad practice, possiamo tornare a parlare del suo opposto, ovvero, il Rich Domain Model. 

    Elenchiamo le caratteristiche che lo definiscono: 

     

    • Forte incapsulamento: Dati e relativi metodi, sono nella stessa classe e le invarianze sono sempre garantite. 
    • Firme dei metodi oneste: i nomi dei metodi devono dichiarare il loro scopo business, i parametri in ingresso e uscita sono di un tipo coerente allo scopo business. 
    • Evitare duplicazione di codice: dove si trovano duplicazioni è un segnale di mancanza di incapsulamento. 
    • Evitare dipendenze esterne: un modello che ha dipendenze esterne, ancor peggio se condivise, è fragile e difficile da testare. 

    Riuscire ad ottenere queste caratteristiche nel nostro modello di dominio rende possibile una drastica diminuzione della complessità da gestire, diminuendo i casi di errori a runtime e rendendo più facile l’estensione e la manutenibilità di quest’ultimo.  

    Il primo passo fondamentale per ottenere le caratteristiche sopra descritte è quello di evitare la primitive obsession. 

     

    “Primitive obsession is when you have the bad practice of using primitive types to represent an object in a domain” 

     

    Ma perché l’utilizzo di tipi primitivi è dannoso? In fondo è probabilmente una delle pratiche più comuni dell’intero mondo dell’OOP. 

    La risposta più immediata: è un problema insiemistico. Facciamo un esempio molto comune. 

    Immaginiamo di avere un metodo che, data l’età di un cliente, calcoli un profilo di rischio. 

    public Risk CalculateRiskProfile(int age) 
    
    {     
    
        return (age < 60) ? Risk.Low : Risk.Medium; 
    
    } 

    Questo metodo, come è evidente, restituisce un profilo di rischio basso con età minore di 60 altrimenti un profilo medio.  

    Il problema di questo metodo è che, così strutturato, permette l’ingresso di valori che non hanno nessun senso in un mondo reale, pensiamo ad una età negativa o ad una età millenaria (age = 2000). 

    Per circoscrivere questi casi è assolutamente necessario aggiungere una validazione del dato in input: 

    public Risk CalculateRiskProfile(int age) 
    
    { 
    
        if (age < 0 || 120 <= age) 
    
            throw new ArgumentException($"{age} is not a valid age"); 
    
     
    
        return (age  runtime exception : 2000 is not a valid age 
    
     

    In questo modo abbiamo fatto sì che il metodo non ci restituisca un profilo di rischio nel caso di utilizzo con parametri errati. 

    Adesso però la firma del metodo “CalculateRiskProfile” è disonesta, in quanto comunica ai suoi client che sarà in grado di restituire un risultato “Risk” a fronte di un QUALSIASI numero intero (+/- 2.147.483.647). 

    Questo è esattamente ciò che intendevo definendo il problema come “insiemistico”. 

    L’insieme dei valori definito da INT è troppo grande, non è abbastanza specifico. Benché da un lato abbiamo risolto il problema di non permettere il passaggio di dati non corretti, tale modifica ne porta con se altri ancora peggiori: 

    1. Il client riceve side effect che il metodo non dichiara, questo porta ad una grossa probabilità di errori a runtime. 
    1. Ogni altro metodo nella nostra code base che utilizza “age”, dovrà riportare la medesima validazione, creando duplicazione. 
    1. Anche estraendo la logica di validazione in una classe, è necessario che sia richiamata in ogni punto nel quale si utilizza “age” (modificando i metodi già scritti, aggiungendolo a quelli che stiamo scrivendo e ricordandosi di farlo per i metodi futuri). Ciò non permette di risolvere la duplicazione. 
    1. Si devono aggiungere unit tests specifici per controllare che ogni metodo che utilizzi “age” sia protetto. 

    La soluzione, semplice e molto efficacie, è quella di dare una sua dignità all’interno del nostro dominio al concetto di “Age”.
    Il primo building block per raggiungere un Rich domain model è infatti il Value Object. 

    “A Value Object is an immutable type that is distinguishable only by the state of its properties” 

    Nel nostro caso, la modifica da fare è la seguente: 

    public class Age : ValueObject 
    
    { 
    
        public int Value { get; private set; } 
    
     
    
        public Age(int value) 
    
        { 
    
            if (value < 0 || 120 <= value) 
    
            throw new ArgumentException($"{value} is not a valid age"); 
    
     
    
            Value = value; 
    
        } 
    
     
    
        protected override IEnumerable<object> GetEqualityComponents() 
    
        { 
    
            yield return Value; 
    
        } 
    
    } 

    Quali sono i benefici di tale approccio? 

    1. Abbiamo rimosso il rischio di possibili duplicazione della logica di validazione. 
    2. Attraverso la classe base ValueObject abbiamo reso Age confrontabile a valore (e non per riferimento), mantenendo la coerenza di comportamento rispetto ad un tipo primitivo. 
    3. In tutta la mia applicazione ho la certezza di lavorare sempre con oggetti Age validi. La logica di validazione incapsulata e l’immutabilità danno questa sicurezza. 
    4. Le firme dei metodi che utilizzano Age sono adesso oneste e non provocano side effect indesiderati e nascosti verso i client. 
    5. Spostiamo a compilation time errori che sarebbero potuti avvenire a runtime. Nell’esempio del metodo “CalculateRiskProfile” un client che avesse definito più variabili di tipo INT avrebbe potuto introdurre un bug passando una variabile sbagliata come parametro. Questo bug non sarebbe stato evidenziato a compilation time. Adesso non è possibile passare come parametro una variabile che non sia di tipo Age. 

     Il secondo blocco fondamentale per avere un modello di dominio ben incapsulato sono ovviamente le Entity. 

    Anche in questo caso la questione, vista dall’alto, può sembrare piuttosto banale, ma a breve ci soffermeremo su di alcuni trade-off piuttosto comuni. 

    Partendo dall’esempio ad inizio articolo delle 2 classi “Customer” e “CustomerService”, ed avendo già evidenziato quali siano le criticità di un approccio che tende a separare dati e comportamenti (creando un Anemic domain model), possiamo stendere il primo passo per incapsulare correttamente in una sola Entity il nostro codice. 

    public class Customer 
    
    { 
    
        public Customer(string name, string surname, Age age, List orders) 
    
        { 
    
            if(!ValidateName(name)) 
    
                throw new ArgumentException(nameOf(name)); 
    
     
    
            if(!ValidateSurname(surname)) 
    
                throw new ArgumentException(nameOf(surname)); 
    
     
    
            Name = name; 
    
            Surname = surname; 
    
            Age = age; 
    
            Order = orders ?? new List(); 
    
        } 
    
     
    
        public string Name {get;private set;} 
    
        public string Surname {get;private set;} 
    
        public Age age {get;set;} 
    
        public List Orders {get;private set;} 
    
     
    
        public void SetName(string name) 
    
        { 
    
            if(!ValidateName(name)) 
    
                throw new ArgumentException(nameOf(name)); 
    
     
    
            Name = name; 
    
        } 
    
     
    
        public void SetSurname(string surname) 
    
        { 
    
            if(!ValidateSurname(surname)) 
    
                throw new ArgumentException(nameOf(surname)); 
    
     
    
            Surname = surname; 
    
        } 
    
     
    
        private static bool ValidateName(string name) 
    
        { 
    
            return !string.IsNullOrWhiteSpace(name); 
    
        } 
    
     
    
        private static bool ValidateSurname(string surname) 
    
        { 
    
            return !string.IsNullOrWhiteSpace(surname); 
    
        } 
    
     
    
     
    
        public Order GetMostExpensiveOrder() { ... } 
    
        public void AddOrder(Order newOrder) { ... } 
    
        public string GetNameAndSurnameConcat() { ... } 
    
    } 

    In questo modo, seppur migliorabile ma funzionale alla trattazione, abbiamo un modello molto meglio incapsulato, che unisce dati e comportamenti, e che non può entrare in uno stato di invalidità. I metodi interni alla classe, o esterni che la usino come parametro in input, avranno la garanzia di lavorare sempre con una istanza valida di Customer e non ci sarà bisogno di spargere più e più volte la logica di validazione nei vari layers della nostra architettura. 

    Sarebbe possibile, inoltre, trasformare anche Name e Surname in Value Objects per estrarre la logica di validazione, i tipi String sono un insieme troppo grande per rappresentare i due concetti, infatti è stato necessario invocare nuovamente la validazione di essi prima del SET delle 2 properties.

    Per introdurre l’ultimo spunto di riflessione riguardo l’incapsulamento delle Entity, proviamo ad immaginare di dover vincolare il cambio del nome del Customer alla presenza o meno di altri clienti col medesimo nome salvati sul database (se già presente non rendere possibile il cambio).  

    Quale sarebbe il layer più corretto per implementare tale logica? 

    Ecco 3 esempi di approcci possibili: 

    1. Delegare all’utilizzatore della Entity Customer l’onere di effettuare il controllo e di interagire con la dipendenza esterna (database) 

    // CustomerController 
    
    public string ChangeName(int customerId, string newName) 
    
    { 
    
        /* The new validation */ 
    
        var existingCustomer = _customerRepository.GetByName(newName); 
    
        if (existingCustomer != null) 
    
            return "Name is already taken"; 
    
     
    
        var customer = _customerRepository.GetById(customerId); 
    
     
    
        customer.SetName(newName); 
    
     
    
        _customerRepository.Save(user); 
    
     
    
        return "OK"; 
    
    } 

    2. Effettuare il controllo all’interno della Entity, compresa l’interazione con il database 

    // Customer 
    
    public void SetName(string name, CustomerRepository repository) 
    
    { 
    
        if(!ValidateName(name)) 
    
            throw new ArgumentException(nameOf(name)); 
    
     
    
        var existingCustomer = repository.GetByName(name); 
    
        if (existingCustomer != null) 
    
            throw new Exception("Name is already taken"); 
    
     
    
        Name = name; 
    
    } 

    3. Delegare all’utilizzare l’interazione del database e, ottenuti i risultati, processarli ed effettuare il controllo all’interno della Entity 

    // CustomerController 
    
    public string ChangeName(int customerId, string newName) 
    
    { 
    
        var allCustomers = _customerRepository.GetAll(); 
    
     
    
        var customer = allCustomers.Single(x =&gt; x.Id == customerId); 
    
     
    
        customer.SetName(newName, allCustomers); 
    
     
    
        _customerRepository.Save(customer); 
    
     
    
        return "OK"; 
    
    } 
    
     
    
    // Customer 
    
    public void SetName(string name, Customer[] allCustomers) 
    
    { 
    
        if(!ValidateName(name)) 
    
            throw new ArgumentException(nameOf(name)); 
    
     
    
        var nameIsTaken = allCustomers.Any(x =&gt; x.Name == name); 
    
        if (nameIsTaken) 
    
            throw new Exception("Name is already taken"); 
    
     
    
        Name = name; 
    
    } 

    Queste 3 possibilità sono quelle che formano ciò che viene chiamato li Cap theorem del domain model. 

    “In theoretical computer science, the CAP theorem, also named Brewer’s, states that any distributed data store can only provide two of the following three guarantees: 

    • Consistency: Every read receives the most recent write or an error. 
    • Availability: Every request receives a (non-error) response, without the guarantee that it contains the most recent write. 
    • Partition tolerance: The system continues to operate despite an arbitrary number of messages being dropped (or delayed) by the network between nodes.” 

     Nel nostro caso le caratteristiche che formano il trilemma sono: 

    • Purezza — quando il modello di dominio non comunica con nessuna dipendenza esterna (esempio 1). 
    • Completezza — Quando tutta la logica di dominio è correttamente incapsulata nel domain layer senza frammentazioni (esempio 2). 
    • Performance — Quando non sono presenti chiamate non necessarie a dipendenze esterne (il contrario dell’esempio 3). 

    Allo stesso modo del teorema formulato da Eric Brewer, anche qui possiamo avere contemporaneamente soltanto 2 dei 3 attributi : 

    Portare tutte le interazioni esterne ai margini del domain model => Preserva la completezza e la purezza del modello ma degrada le prestazioni. 

    Iniettare le dipendenze esterne nel modello di dominio => Mantiene le prestazioni e la completezza del modello di dominio, a scapito della sua purezza. 

    Dividere la responsabilità tra il livello di dominio e i controller => Aiuta prestazioni e purezza del modello di dominio, ma sacrifica la completezza. Con questo approccio, è necessario introdurre business logic nel controller. 

    Conclusioni

    In conclusione, avere oggetti correttamente incapsulati così da ottenere quello che viene definito un Rich domain model è un pratica che migliora molto l’affidabilità del nostro software.  

    Per implementare correttamente il modello di dominio è quindi fondamentale evitare la primitive obsession, fare un corretto uso di Value Objects ed Entities così da ridurre la superficie delle API fornite ai client per evitare che gli oggetti possano entrare in uno stato non valido. 

    Uno degli ostacoli da affrontare è quello che viene definito il Cap theorem del domain model dove, nello scegliere tra purezza, completezza e performance saremo costretti a sacrificare sempre una delle tre caratteristiche; come spesso avviene per molte decisioni nello sviluppo software non c’è una risposta corretta ma ogni scelta comporta dei trade-off da valutare caso per caso.