Menu
Contattaci
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 TeamLa 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:
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.
“È 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:
public class Customer
{
public string Name {get;set;}
public string Surname {get;set;}
public List Orders {get;set;}
}
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:
public class CustomerService
{
public Order GetMostExpensiveOrder(Customer customer) { ... }
public void AddOrder(Customer customer, Order newOrder) { ... }
public string GetNameAndSurnameConcat(Customer customer) { ... }
}
Questo modello è problematico e considerato un anti-pattern principalmente per 3 fattori strettamente legati tra loro:
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:
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:
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?
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:
// 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 => 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 => 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:
Nel nostro caso le caratteristiche che formano il trilemma sono:
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.
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.
https://www.amazon.it/Functional-Programming-C-Enrico-Buonanno
https://www.amazon.it/Domain-Driven-Design-Tackling-Complexity-Software
https://www.amazon.it/Implementing-Domain-Driven-Design-Vaughn-Vernon
https://www.amazon.it/Hands-Domain-Driven-Design-NET
https://enterprisecraftsmanship.com/posts/domain-model-purity-completeness/
https://enterprisecraftsmanship.com/posts/refactoring-from-anemic-domain-model-towards-a-rich-one/
https://khorikov.org/posts/2021-05-17-domain-model-purity/
https://app.pluralsight.com/library/courses/domain-driven-design-in-practice/table-of-contents
https://app.pluralsight.com/library/courses/refactoring-anemic-domain-model/table-of-contents
Segui Giuneco sui social