Strongly Typed IDs e Value Objects (DDD Light)
- #csharp
- #programmazione
- #ddd
- #value-objects
- #strongly-typed-ids
- #domain-driven-design
In questa lezione
- 1. Il problema con i tipi primitivi
- 2. Entity, Value Object e Aggregate Root
- 3. Il contratto dell’uguaglianza
- 4. Implementazione manuale: IEquatable<T> e IComparable<T>
- 5. record e record struct
- 6. Validazione con factory e Result<T>
- 7. Attraversare i confini: JSON e model binding
- 7.1 System.Text.Json
- 7.2 Route e query string
- 8. Persistenza e automazione
- 8.1 EF Core e Value Converter
- 8.2 Source generator: AndrewLock.StronglyTypedId
- 9. Quale tipo scegliere e quando non usarli
- 10. Quiz
- 11. Esercizi
- 11.1 ID tipizzati per un e-commerce
- 11.2 Value Object Email
- 11.3 Value Object Money
- 11.4 ID tipizzato end-to-end
1. Il problema con i tipi primitivi
Immagina questo codice:
public void ProcessaOrdine(int ordineId, int clienteId, int prodottoId) { /* ... */ }
// Il compilatore accetta la chiamata anche se semanticamente è sbagliata
ProcessaOrdine(prodottoId, ordineId, clienteId);
Per il compilatore tre int sono solo tre int. Per il dominio, invece, OrdineId, ClienteId e ProdottoId sono concetti diversi.
Questo è un caso classico di Primitive Obsession: usare tipi primitivi per concetti di dominio più ricchi. Le conseguenze tipiche sono parametri invertiti senza errori, validazioni duplicate in più punti e API meno leggibili (string email dice meno di Email email).
Con uno strongly typed ID l’errore emerge a compile time:
public readonly record struct OrdineId(int Value);
public readonly record struct ClienteId(int Value);
public void ProcessaOrdine(OrdineId ordineId, ClienteId clienteId) { /* ... */ }
var ordine = new OrdineId(42);
var cliente = new ClienteId(7);
ProcessaOrdine(ordine, cliente); // OK
ProcessaOrdine(cliente, ordine); // errore CS1503: impossibile convertire da 'ClienteId' a 'OrdineId'
Il punto chiave: un tipo non dovrebbe contenere solo dati, ma anche significato semantico.
2. Entity, Value Object e Aggregate Root
Nel Domain-Driven Design (DDD) non tutti gli oggetti hanno lo stesso ruolo:
- Entity: definita dalla sua identità. Un
Ordinepuò cambiare stato e totale, ma resta lo stesso ordine finché ha lo stessoOrdineId. - Value Object: definito dai suoi valori. Non ha identità propria, in genere è immutabile, ha uguaglianza per valore e incapsula le proprie regole di validazione. Esempi:
Email,Money,Address,DateRangee gli stessiOrdineId,ClienteId. - Aggregate Root: l’entità principale di un gruppo di oggetti correlati, unico punto di ingresso per modificarli mantenendo le invarianti.
// Entity: conta l'identità
public sealed class Cliente
{
public ClienteId Id { get; init; }
public string Nome { get; set; } = "";
}
// Value Object: conta il valore
public readonly record struct Money(decimal Amount, string Currency);
var c1 = new Cliente { Id = new ClienteId(1), Nome = "Mario" };
var c2 = new Cliente { Id = new ClienteId(1), Nome = "Mario Rossi" };
Console.WriteLine(c1.Id == c2.Id); // True: stesso cliente
Console.WriteLine(new Money(10m, "EUR") == new Money(10m, "EUR")); // True: stesso valore
graph TD
A[Ordine - Aggregate Root]
B[RigaOrdine]
C[RigaOrdine]
D[IndirizzoSpedizione - Value Object]
A --> B
A --> C
A --> D
Suggerimento
Una domanda rapida per distinguere i due casi: per una Entity chiediti “è la stessa cosa nel tempo?”, per un Value Object “contiene gli stessi valori?“.
3. Il contratto dell’uguaglianza
L’uguaglianza di un Value Object deve essere una relazione di equivalenza:
- riflessiva:
x.Equals(x)è sempre vero; - simmetrica: se
x.Equals(y)alloray.Equals(x); - transitiva: se
x.Equals(y)ey.Equals(z)allorax.Equals(z).
In più, in .NET vale una regola fondamentale: se due oggetti sono uguali, devono avere lo stesso GetHashCode(). Se il contratto si rompe, HashSet<T>, Dictionary<TKey, TValue>, Distinct() e gli ordinamenti iniziano a comportarsi in modo imprevedibile.
Esempio con un Email che normalizza il valore: chiavi diverse nella forma, uguali nel dominio.
var set = new HashSet<Email>
{
new Email("utente@example.com"),
new Email("UTENTE@Example.com"), // normalizzata in lowercase: duplicato
};
Console.WriteLine(set.Count); // 1
Pericolo
Se ridefinisci Equals ma non GetHashCode (o li calcoli su campi diversi), un oggetto inserito in un Dictionary può diventare “irrintracciabile”: ContainsKey restituisce false anche per una chiave uguale.
4. Implementazione manuale: IEquatable<T> e IComparable<T>
Scrivendo un Value Object a mano, i due contratti principali sono:
IEquatable<T>: uguaglianza tipizzata, senza boxing né cast a runtime (a differenza diEquals(object?));IComparable<T>: ordinamento naturale, perSort(),OrderBye gli operatori<,>.
public readonly struct OrdineId : IEquatable<OrdineId>, IComparable<OrdineId>
{
public int Value { get; }
public OrdineId(int value)
{
if (value <= 0)
throw new ArgumentOutOfRangeException(nameof(value), "OrdineId deve essere positivo");
Value = value;
}
public bool Equals(OrdineId other) => Value == other.Value;
public override bool Equals(object? obj) => obj is OrdineId other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
public int CompareTo(OrdineId other) => Value.CompareTo(other.Value);
public override string ToString() => Value.ToString();
public static bool operator ==(OrdineId l, OrdineId r) => l.Equals(r);
public static bool operator !=(OrdineId l, OrdineId r) => !l.Equals(r);
public static bool operator <(OrdineId l, OrdineId r) => l.CompareTo(r) < 0;
public static bool operator >(OrdineId l, OrdineId r) => l.CompareTo(r) > 0;
public static explicit operator int(OrdineId id) => id.Value;
public static explicit operator OrdineId(int value) => new(value);
}
Uso:
var ids = new List<OrdineId> { new(30), new(10), new(20) };
ids.Sort(); // usa CompareTo
Console.WriteLine(string.Join(", ", ids)); // 10, 20, 30
Console.WriteLine(new OrdineId(10) < new OrdineId(20)); // True
Attenzione
CompareTo deve essere coerente con Equals: se x.Equals(y) allora x.CompareTo(y) == 0. Altrimenti un SortedSet<T> e un HashSet<T> possono dare risultati diversi sugli stessi dati.
5. record e record struct
I record eliminano quasi tutto il boilerplate visto sopra. Esistono in due forme:
record class(o solorecord): reference type, allocato su heap, uguaglianza per valore;record struct: value type, di norma nessuna allocazione heap (salvo boxing), uguaglianza per valore.
Il compilatore genera automaticamente Equals, GetHashCode, == / !=, ToString(), Deconstruct(...) (per i parametri posizionali) e il supporto a with.
public readonly record struct Money(decimal Amount, string Currency);
var a = new Money(10m, "EUR");
var b = new Money(10m, "EUR");
Console.WriteLine(a == b); // True
Console.WriteLine(a); // Money { Amount = 10, Currency = EUR }
var (amount, currency) = a; // Deconstruct
var c = a with { Amount = 15m }; // nuova istanza, a resta invariato
Console.WriteLine(c.Amount); // 15
Se serve validazione, si rinuncia alla sintassi posizionale e si scrive il costruttore:
public readonly record struct ProdottoId
{
public int Value { get; }
public ProdottoId(int value)
{
if (value <= 0)
throw new ArgumentOutOfRangeException(nameof(value), "ProdottoId deve essere positivo");
Value = value;
}
public override string ToString() => $"ProdottoId({Value})";
}
Attenzione
Una struct ha sempre un valore di default: default(ProdottoId) o new ProdottoId() producono Value = 0 senza passare dalla validazione. Lo stesso vale per with, che copia i campi senza richiamare il costruttore. Se l’invariante è critica, valuta un record class o controlla il valore anche nei metodi di dominio.
Nota
Nei record class con ereditarietà il compilatore usa una proprietà nascosta EqualityContract: così un Fattura("1") non risulta mai uguale a un Documento("1"), anche con gli stessi valori. I record struct non hanno ereditarietà, quindi il problema non si pone.
6. Validazione con factory e Result<T>
Per input utente o dati esterni, un valore non valido non è davvero “eccezionale”: meglio rappresentare il fallimento come un valore esplicito. Lo schema tipico è costruttore privato + factory method che restituisce un Result<T>.
public sealed class Result<T>
{
private Result(bool ok, T? value, string? error) => (IsSuccess, Value, Error) = (ok, value, error);
public bool IsSuccess { get; }
public bool IsFailure => !IsSuccess;
public T? Value { get; }
public string? Error { get; }
public static Result<T> Success(T value) => new(true, value, null);
public static Result<T> Failure(string error) => new(false, default, error);
public T ValueOrThrow() => IsSuccess ? Value! : throw new InvalidOperationException(Error);
}
public readonly record struct Email
{
public string Value { get; }
private Email(string value) => Value = value;
public static Result<Email> Create(string input)
{
if (string.IsNullOrWhiteSpace(input))
return Result<Email>.Failure("L'email non può essere vuota");
var normalized = input.Trim().ToLowerInvariant();
if (!normalized.Contains('@'))
return Result<Email>.Failure("Formato email non valido");
return Result<Email>.Success(new Email(normalized));
}
public static implicit operator string(Email email) => email.Value; // non può fallire
public static explicit operator Email(string value) => Create(value).ValueOrThrow(); // può fallire
public override string ToString() => Value;
}
Uso:
foreach (var input in new[] { "Utente@Example.com", "", "senza-chiocciola" })
{
var result = Email.Create(input);
Console.WriteLine(result.IsSuccess ? $"OK: {result.Value}" : $"Errore: {result.Error}");
}
// OK: utente@example.com
// Errore: L'email non può essere vuota
// Errore: Formato email non valido
Suggerimento
Regola per le conversioni: implicit solo se la conversione non può mai fallire (Email verso string), explicit quando può fallire o perdere informazione (string verso Email). Lo stesso concetto di Result<T> nei linguaggi funzionali si chiama Either<Errore, Valore>.
I Value Objects possono anche ospitare invarianti di dominio. Esempio con Money:
public readonly record struct Money(decimal Amount, string Currency)
{
public static Result<Money> Create(decimal amount, string currency)
{
if (amount < 0) return Result<Money>.Failure("L'importo non può essere negativo");
if (string.IsNullOrWhiteSpace(currency)) return Result<Money>.Failure("La valuta è obbligatoria");
return Result<Money>.Success(new Money(amount, currency.ToUpperInvariant()));
}
public Result<Money> Add(Money other) =>
Currency != other.Currency
? Result<Money>.Failure("Non si possono sommare valute diverse")
: Result<Money>.Success(this with { Amount = Amount + other.Amount });
}
var eur = Money.Create(10m, "eur").ValueOrThrow();
var usd = Money.Create(5m, "USD").ValueOrThrow();
Console.WriteLine(eur.Add(eur).Value); // Money { Amount = 20, Currency = EUR }
Console.WriteLine(eur.Add(usd).Error); // Non si possono sommare valute diverse
7. Attraversare i confini: JSON e model binding
Prima o poi un Value Object deve passare da JSON, route, query string e database.
7.1 System.Text.Json
Senza configurazione, OrdineId(42) viene serializzato come { "Value": 42 }. Di solito si vuole il solo valore 42, e si ottiene con un converter:
using System.Text.Json;
using System.Text.Json.Serialization;
public sealed class OrdineIdJsonConverter : JsonConverter<OrdineId>
{
public override OrdineId Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> new(reader.GetInt32());
public override void Write(Utf8JsonWriter writer, OrdineId value, JsonSerializerOptions options)
=> writer.WriteNumberValue(value.Value);
}
// Registrazione (Minimal API)
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.Converters.Add(new OrdineIdJsonConverter()));
In alternativa puoi decorare il tipo con [JsonConverter(typeof(OrdineIdJsonConverter))], così il converter vale ovunque, anche fuori da ASP.NET Core. Per ID basati su Guid o string usa GetGuid / GetString e WriteStringValue.
7.2 Route e query string
Per parametri semplici basta un TypeConverter:
using System.ComponentModel;
using System.Globalization;
[TypeConverter(typeof(OrdineIdTypeConverter))]
public readonly record struct OrdineId(int Value);
public sealed class OrdineIdTypeConverter : TypeConverter
{
public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) =>
sourceType == typeof(string) || base.CanConvertFrom(context, sourceType);
public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) =>
value is string s && int.TryParse(s, out var n) ? new OrdineId(n) : throw new FormatException("OrdineId non valido");
}
// Ora il binding funziona direttamente
[HttpGet("{id}")]
public IActionResult GetById(OrdineId id) => Ok(id.Value);
Nota
Da .NET 7 esiste anche IParsable<T>: se il tipo espone un metodo statico TryParse(string, IFormatProvider?, out T), Minimal API e controller MVC lo usano per il binding senza bisogno di TypeConverter.
Quando serve controllo completo (errori dettagliati in ModelState, dipendenze dal DI, mapping da Result<T>) si usa un IModelBinder:
using Microsoft.AspNetCore.Mvc.ModelBinding;
public sealed class EmailModelBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext ctx)
{
var raw = ctx.ValueProvider.GetValue(ctx.ModelName).FirstValue;
if (raw is null) return Task.CompletedTask;
var result = Email.Create(raw);
if (result.IsSuccess)
ctx.Result = ModelBindingResult.Success(result.Value);
else
ctx.ModelState.AddModelError(ctx.ModelName, result.Error!);
return Task.CompletedTask;
}
}
8. Persistenza e automazione
8.1 EF Core e Value Converter
Il database salva primitivi, quindi EF Core deve sapere come convertire avanti e indietro:
public class Ordine
{
public OrdineId Id { get; set; }
public ClienteId ClienteId { get; set; }
public decimal Totale { get; set; }
}
public readonly record struct ClienteId(Guid Value);
public class AppDbContext : DbContext
{
public DbSet<Ordine> Ordini => Set<Ordine>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Ordine>(e =>
{
e.Property(o => o.Id).HasConversion(id => id.Value, v => new OrdineId(v));
e.Property(o => o.ClienteId).HasConversion(id => id.Value, v => new ClienteId(v));
});
}
}
Le query restano tipizzate: db.Ordini.Where(o => o.ClienteId == clienteId) viene tradotta in SQL sul Guid sottostante.
Con molti ID tipizzati, invece di ripetere HasConversion per ogni proprietà, puoi registrare un ValueConverter una sola volta in ConfigureConventions con Properties<OrdineId>().HaveConversion<OrdineIdConverter>().
8.2 Source generator: AndrewLock.StronglyTypedId
Scrivere a mano wrapper, Equals, GetHashCode, converter JSON, TypeConverter ed EF Core per ogni ID diventa ripetitivo. La libreria AndrewLock.StronglyTypedId usa un source generator per produrre tutto questo codice:
using StronglyTypedIds;
[StronglyTypedId] // Guid di default
public partial struct OrdineId;
[StronglyTypedId(Template.Int)] // anche Long, String, ...
public partial struct ProdottoId;
Conviene quando hai molti ID e vuoi convenzioni uniformi senza errori di copia-incolla. Con 1 o 2 tipi, l’implementazione manuale con readonly record struct resta più semplice da leggere.
9. Quale tipo scegliere e quando non usarli
| Approccio | Categoria | Uguaglianza di default | Allocazione | Note |
|---|---|---|---|---|
struct | Value type | Manuale (o ValueType.Equals, lento) | In genere nessuna su heap | Massimo controllo, molto verboso |
record struct | Value type | Automatica per valore | In genere nessuna su heap | Ottimo per VO piccoli e ID |
class | Reference type | Per riferimento, salvo override | Heap | Flessibile, richiede disciplina |
record class | Reference type | Automatica per valore | Heap | VO grandi o con gerarchie |
Regola pratica: ID e VO piccoli come readonly record struct; VO con molti campi, ereditarietà o invarianti che non devono essere aggirabili con default come record class.
Attenzione
I value type vengono boxati quando passano per object o per un’interfaccia non generica, e un value type grande è costoso da copiare a ogni passaggio. Sopra i 16-24 byte circa conviene valutare un record class.
Non ogni string merita un tipo dedicato. Segnali di over-engineering: il tipo non incapsula nessuna regola, non migliora la leggibilità, è usato una sola volta, oppure il dominio è un CRUD banale dove converter e binder costano più del beneficio.
Usa un Value Object quando aggiunge significato, sicurezza e coesione delle regole.
10. Quiz
Mettiti alla prova
0/10 risposte
Qual è il problema principale degli ID con tipo primitivo (int, Guid)?
Un Value Object in DDD è identificato da:
Se due oggetti sono uguali secondo Equals(), cosa deve valere per GetHashCode()?
I
record structin C# hanno l'uguaglianza:Quale vantaggio principale offrono gli Strongly Typed IDs?
Per usare Strongly Typed IDs con EF Core è necessario:
Il metodo Money.Add() che rifiuta la somma di valute diverse è un esempio di:
Il factory method con costruttore privato nei Value Objects serve per:
Cosa produce
default(ProdottoId)se ProdottoId è un readonly record struct con validazione nel costruttore?Quale conversione è corretto rendere implicit per il Value Object Email?
11. Esercizi
11.1 ID tipizzati per un e-commerce
Scenario: Vuoi proteggere un sistema e-commerce dalla confusione tra ID diversi.
Consegna:
- Crea
OrdineId,ClienteId,ProdottoIdcomereadonly record structcon unint Value. - Aggiungi validazione (es. valore positivo).
- Scrivi un metodo
ProcessaOrdine(OrdineId ordineId, ClienteId clienteId)e verifica che il compilatore impedisca di invertire i parametri. - Testa l’uguaglianza: due
OrdineId(1)devono essere uguali.
Obiettivo: comprendere come i Strongly Typed IDs proteggono il codice a compile time.
11.2 Value Object Email
Scenario: Vuoi un tipo Email che garantisce sempre un valore valido.
Consegna:
- Crea
Emailcomereadonly record structcon costruttore privato. - Implementa il factory method
Create(string input)che restituisceResult<Email>(non vuota, contiene @, dominio con almeno un punto). - Verifica con un
HashSet<Email>che"A@B.it"e"a@b.it"siano considerate uguali. - Usa
Emailcome proprietà di una classeUtente.
Obiettivo: costruire un Value Object con business rules e gestione errori senza eccezioni.
11.3 Value Object Money
Scenario: Gestisci importi monetari in modo sicuro.
Consegna:
- Crea
MoneyconAmount (decimal)eCurrency (string). - Implementa
Add(Money altro)eSubtract(Money altro)con controllo valuta, restituendoResult<Money>. - Aggiungi factory method
Euro(decimal)eDollaro(decimal). - Dimostra che due
Money(10, "EUR")sono uguali.
Obiettivo: implementare un Value Object multi-proprietà con operazioni di dominio.
11.4 ID tipizzato end-to-end
Scenario: Vuoi usare OrdineId in tutta l’applicazione, dalla route HTTP al database.
Consegna:
- Crea un
JsonConverter<OrdineId>che serializzi l’ID come numero semplice. - Aggiungi un
TypeConverter(o implementaIParsable<OrdineId>) per usarlo come parametro di route. - Configura il Value Converter in EF Core.
- Esponi un endpoint
GET /ordini/{id}che restituisce l’ordine in JSON con"id": 42e non"id": { "Value": 42 }.
Obiettivo: far attraversare a un Value Object tutti i confini applicativi senza perdere la tipizzazione.