Sostieni AppuntiFacili con una piccola donazione su PayPal
Dona con PayPalImmagina questo codice:
public void ProcessaOrdine(int ordineId, int clienteId, int prodottoId)
{
// Il compilatore accetta la chiamata anche se semanticamente è sbagliata
ProcessaOrdine(prodottoId, ordineId, clienteId);
}
Dal punto di vista del compilatore, tre int sono semplicemente tre int. Dal punto di vista del dominio, invece, OrdineId, ClienteId e ProdottoId rappresentano concetti diversi.
Questo è un caso classico di Primitive Obsession: si usano tipi primitivi per rappresentare concetti di dominio più ricchi di quanto il tipo riesca a esprimere.
Le conseguenze pratiche sono:
string email dice meno di Email email.graph LR
A[Primitivo: string email] --> B[Qualsiasi stringa]
C[Value Object: Email] --> D[Solo valori validi]
B --> E[Errori a runtime]
D --> F[Maggiore sicurezza a compile time e runtime]
Il punto chiave è questo: un tipo non dovrebbe contenere solo dati, ma anche significato semantico.
Nel Domain-Driven Design (DDD), non tutti gli oggetti del dominio hanno lo stesso ruolo.
Una Entity è un oggetto definito dalla sua identità, non solo dai suoi dati.
Esempio: un Ordine può cambiare stato, totale, righe, indirizzo di spedizione, ma rimane sempre lo stesso ordine se il suo OrdineId è lo stesso.
public class Ordine
{
public OrdineId Id { get; private set; }
public Email EmailCliente { get; private set; }
public decimal Totale { get; private set; }
}
Due Ordine con lo stesso ID rappresentano concettualmente la stessa entità, anche se alcuni campi cambiano nel tempo.
Un Value Object è definito dai suoi valori interni:
Esempi tipici:
EmailMoneyAddressDateRangeOrdineId, ClienteId, ProdottoIdSe due Email contengono lo stesso valore normalizzato, sono uguali. Non interessa “quale istanza” siano, ma “quale valore” rappresentino.
Un Aggregate Root è, in breve, l’entità principale di un aggregato: il punto di ingresso attraverso cui il resto del sistema modifica in modo consistente un gruppo di oggetti correlati.
Per esempio:
graph TD
A[Ordine - Aggregate Root]
B[RigaOrdine]
C[RigaOrdine]
D[IndirizzoSpedizione - Value Object]
A --> B
A --> C
A --> D
L’idea è che le invarianti dell’aggregato vengano protette passando dal root (Ordine), non modificando direttamente ogni oggetto interno in modo arbitrario.
La distinzione più importante è:
In forma intuitiva:
Quando diciamo che un Value Object usa uguaglianza strutturale, intendiamo che due istanze sono uguali se tutti i loro componenti significativi sono uguali.
Per esempio:
var a = new Email("utente@example.com");
var b = new Email("UTENTE@example.com");
Se il tipo normalizza il valore in lowercase, a e b possono risultare uguali perché rappresentano lo stesso valore di dominio.
Per le Entity:
public sealed class Cliente
{
public ClienteId Id { get; init; }
public string Nome { get; init; } = "";
}
di solito interessa:
cliente1.Id == cliente2.Id
Per un Value Object:
public readonly record struct Money(decimal Amount, string Currency);
interessa invece:
new Money(10m, "EUR") == new Money(10m, "EUR")
L’uguaglianza corretta di un Value Object dovrebbe comportarsi come una relazione di equivalenza.
Una relazione ~ su un insieme S è una relazione di equivalenza se vale:
Questa proprietà si chiama riflessività: ogni valore deve essere uguale a sé stesso.
Questa proprietà si chiama simmetria: se x è uguale a y, allora y è uguale a x.
Questa proprietà si chiama transitività: se x è uguale a y e y è uguale a z, allora x deve essere uguale a z.
Inoltre, in .NET vale una regola pratica fondamentale:
cioè se due oggetti sono uguali, devono produrre lo stesso GetHashCode().
Se queste proprietà non valgono, si rompono collezioni e algoritmi:
HashSet<T>Dictionary<TKey, TValue>Per questo un buon Value Object non deve solo “sembrare” corretto: deve rispettare formalmente il contratto dell’uguaglianza.
struct, IEquatable<T> e IComparable<T>Se implementi un Value Object manualmente, i due contratti più importanti sono spesso:
IEquatable<T> per l’uguaglianza tipizzata efficiente;IComparable<T> per l’ordinamento naturale.IEquatable<T> è utileEquals(object?) accetta un object, quindi può introdurre:
IEquatable<T> permette invece una comparazione fortemente tipizzata.
IComparable<T> è utileServe quando vuoi:
Sort();<, >, <=, >=.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 left, OrdineId right) => left.Equals(right);
public static bool operator !=(OrdineId left, OrdineId right) => !left.Equals(right);
public static bool operator <(OrdineId left, OrdineId right) => left.CompareTo(right) < 0;
public static bool operator >(OrdineId left, OrdineId right) => left.CompareTo(right) > 0;
public static bool operator <=(OrdineId left, OrdineId right) => left.CompareTo(right) <= 0;
public static bool operator >=(OrdineId left, OrdineId right) => left.CompareTo(right) >= 0;
public static explicit operator int(OrdineId id) => id.Value;
public static explicit operator OrdineId(int value) => new(value);
}
readonly struct evita mutazioni indesiderate.Equals, devi ridefinire anche GetHashCode.CompareTo dovrebbe essere coerente con l’uguaglianza: se x.Equals(y) allora x.CompareTo(y) == 0.record e record struct: come funziona davvero l’uguaglianzaI record sono stati introdotti per semplificare i modelli basati su dati. In DDD light sono spesso perfetti per i Value Objects.
record class e record structrecord class: reference type, allocazione su heap, value equality sintetizzata.record struct: value type, di norma nessuna allocazione heap se non viene boxato, value equality sintetizzata.Per un record, il compilatore genera tipicamente:
EqualsGetHashCode== e !=Deconstruct(...)ToString()withEsempio:
public readonly record struct ClienteId(Guid Value);
var id1 = new ClienteId(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"));
var id2 = new ClienteId(Guid.Parse("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"));
Console.WriteLine(id1 == id2); // true
var (rawGuid) = id1; // Deconstruct automatico
Console.WriteLine(rawGuid);
EqualityContractNei record class esiste anche il concetto di EqualityContract, usato per far sì che l’uguaglianza tenga conto del tipo runtime corretto nelle gerarchie di ereditarietà.
public record Documento(string Numero);
public record Fattura(string Numero) : Documento(Numero);
Qui il compilatore usa internamente EqualityContract per evitare che record di tipi diversi risultino uguali solo perché hanno gli stessi valori.
In pratica:
record class con ereditarietà: il tipo concreto conta;record struct: niente ereditarietà, quindi niente problema di EqualityContract nello stesso senso.DeconstructIl metodo Deconstruct consente di spacchettare il record:
public readonly record struct Money(decimal Amount, string Currency);
var money = new Money(10m, "EUR");
var (amount, currency) = money;
È utile per pattern matching, test, assegnazioni multiple e codice molto leggibile.
with expressionIl with crea una nuova istanza copiando quella esistente e cambiando solo alcuni membri.
public record Money(decimal Amount, string Currency);
var originale = new Money(10m, "EUR");
var aggiornato = originale with { Amount = 15m };
Per i Value Objects questo ha senso solo se il tipo rimane valido dopo la copia. Per questo spesso:
with.record struct con validazionepublic 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 static explicit operator int(ProdottoId id) => id.Value;
public static explicit operator ProdottoId(int value) => new(value);
public override string ToString() => $"ProdottoId({Value})";
}
Result<T> / EitherLanciare eccezioni va bene quando hai davvero una situazione eccezionale. Ma per input utente o dati esterni spesso è meglio rappresentare il fallimento come un valore esplicito.
Gli operatori di conversione permettono a un Value Object di convivere con tipi primitivi senza perdere ergonomia.
implicit: conversione automatica, da usare con cautela.explicit: richiede cast esplicito, più sicura quando c’è rischio di ambiguità o validazione.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;
public static explicit operator Email(string value) =>
Create(value).ValueOrThrow();
public override string ToString() => Value;
}
Qui:
Email -> string può essere implicita perché non fallisce;string -> Email è meglio esplicita perché può fallire.Result<T>Un Result<T> modella il fatto che un’operazione può riuscire o fallire.
public sealed class Result<T>
{
private Result(bool isSuccess, T? value, string? error)
{
IsSuccess = isSuccess;
Value = value;
Error = 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);
}
Uso:
var result = Email.Create("utente@example.com");
if (result.IsFailure)
{
Console.WriteLine(result.Error);
}
else
{
Email email = result.Value!;
Console.WriteLine(email);
}
EitherLo stesso concetto può essere espresso come Either<Errore, Valore>:
È molto usato in stili più funzionali, ma il concetto è lo stesso: il fallimento è un dato, non un’eccezione.
Moneypublic 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)
{
if (Currency != other.Currency)
return Result<Money>.Failure("Non si possono sommare valute diverse");
return Result<Money>.Success(this with { Amount = Amount + other.Amount });
}
}
Un Value Object non vive solo nel dominio. Prima o poi deve passare da:
flowchart LR
A[HTTP Request] --> B[Model Binding]
B --> C[Controller]
C --> D[Application Service]
D --> E[Domain]
E --> F[JSON Response]
System.Text.Json e custom converterSe hai un Value Object come:
public readonly record struct OrdineId(int Value);
potresti voler serializzare solo il valore interno:
42
invece di:
{ "value": 42 }
Puoi farlo con un converter personalizzato:
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)
{
var value = reader.GetInt32();
return new OrdineId(value);
}
public override void Write(Utf8JsonWriter writer, OrdineId value, JsonSerializerOptions options)
{
writer.WriteNumberValue(value.Value);
}
}
Registrazione:
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.Converters.Add(new OrdineIdJsonConverter());
});
Per Value Objects basati su stringa, il converter legge e scrive stringhe; per Guid, usa WriteStringValue o parsing dedicato.
TypeConverterPer route e query param semplici, un TypeConverter è spesso la soluzione più leggera:
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)
{
if (value is string s && int.TryParse(s, out var parsed))
return new OrdineId(parsed);
throw new FormatException("OrdineId non valido");
}
}
A quel punto puoi scrivere:
[HttpGet("{id}")]
public IActionResult GetById(OrdineId id)
{
return Ok(id.Value);
}
IModelBinderSe la logica di binding è più complessa, puoi creare un binder personalizzato:
Result<T>.using Microsoft.AspNetCore.Mvc.ModelBinding;
public sealed class EmailModelBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var raw = bindingContext.ValueProvider.GetValue(bindingContext.ModelName).FirstValue;
if (raw is null)
return Task.CompletedTask;
var result = Email.Create(raw);
if (result.IsSuccess)
{
bindingContext.Result = ModelBindingResult.Success(result.Value);
}
else
{
bindingContext.ModelState.AddModelError(bindingContext.ModelName, result.Error!);
}
return Task.CompletedTask;
}
}
Idea pratica:
TypeConverter per tipi semplici;IModelBinder quando serve controllo completo.AndrewLock.StronglyTypedIdEF Core deve sapere come mappare un Value Object sul tipo persistito.
public class AppDbContext : DbContext
{
public DbSet<Ordine> Ordini => Set<Ordine>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Ordine>(entity =>
{
entity.Property(o => o.Id)
.HasConversion(
id => id.Value,
value => new OrdineId(value));
entity.Property(o => o.ClienteId)
.HasConversion(
id => id.Value,
value => new ClienteId(value));
});
}
}
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);
Questo evita di perdere i vantaggi dei tipi forti solo perché il database salva primitivi.
AndrewLock.StronglyTypedIdSe devi creare molti strongly typed IDs, scrivere a mano sempre:
Equals;GetHashCode;ToString;diventa ripetitivo.
La libreria AndrewLock.StronglyTypedId usa un source generator per generare automaticamente il codice boilerplate.
Esempio concettuale:
using StronglyTypedIds;
[StronglyTypedId]
public partial struct OrdineId;
Oppure con tipo sottostante specifico:
using StronglyTypedIds;
[StronglyTypedId(Template.Guid)]
public partial struct ClienteId;
In base al template scelto, il generatore può produrre codice per:
intlongGuidstringe spesso integrare:
System.Text.JsonTypeConverterNewtonsoft.JsonIl vantaggio principale è ridurre il codice ripetitivo mantenendo un modello coerente in tutta la soluzione.
Un source generator ha senso quando:
Se invece hai solo 1 o 2 tipi, una implementazione manuale può essere più semplice e leggibile.
| Approccio | Categoria | Uguaglianza di default | Allocazione | Boxing | Note |
|---|---|---|---|---|---|
struct | Value type | Manuale o ValueType.Equals | In genere stack/inlined | Possibile | Molto efficiente, ma più verboso |
record struct | Value type | Automatica per valore | In genere stack/inlined | Possibile | Ottimo compromesso per VO piccoli |
class | Reference type | Per riferimento, salvo override | Heap | No per il tipo stesso | Flessibile, ma richiede più disciplina |
record class | Reference type | Automatica per valore | Heap | No per il tipo stesso | Ottimo per VO grandi o gerarchie dati |
Osservazioni pratiche:
struct e record struct evitano allocazioni heap nella maggior parte dei casi.object o interfaccia non generica, può avvenire boxing.record class e class allocano su heap, ma evitano copie implicite dell’intera struttura.Regola pratica:
readonly record struct.record class.record class.readonly struct.Non ogni string merita un tipo dedicato. Alcuni segnali di over-engineering:
Esempi in cui potresti evitare un Value Object:
Il criterio corretto non è “usare sempre Value Objects”, ma:
usare un Value Object quando aggiunge significato, sicurezza e coesione delle regole.
Gli Strongly Typed IDs sono un caso particolare di Value Object. Servono a spostare errori:
Se implementati bene, combinano:
Qual è il problema principale degli ID con tipo primitivo (int, Guid)?
Un Value Object in DDD è identificato da:
Quale caratteristica fondamentale ha un Value Object?
I `record struct` in C# 9+ hanno l'uguaglianza:
Cosa vantaggio principale offrono gli Strongly Typed IDs?
Per usare Strongly Typed IDs con EF Core è necessario:
Il Value Object Money con metodo Aggiungi() che lancia eccezione se le valute sono diverse è un esempio di:
Quale tipo ha zero allocazioni heap in C#?
Il factory method pattern nei Value Objects serve per:
Due oggetti Email con lo stesso valore sono uguali in un record struct?
Scenario: Vuoi proteggere un sistema e-commerce dalla confusione tra ID diversi.
Consegna:
OrdineId, ClienteId, ProdottoId come readonly record struct con un int Value.ProcessaOrdine(OrdineId ordineId, ClienteId clienteId) e verifica che il compilatore impedisca di invertire i parametri.OrdineId(1) devono essere uguali.Obiettivo: comprendere come i Strongly Typed IDs proteggono il codice a compile time.
Scenario: Vuoi un tipo Email che garantisce sempre un valore valido.
Consegna:
Email come readonly record struct con validazione nel costruttore (non vuota, contiene @, dominio valido).Crea(string input) che restituisce (Email?, string?).Email come proprietà di una classe Utente.Obiettivo: costruire un Value Object con business rules e gestione errori senza eccezioni.
Scenario: Gestisci importi monetari in modo sicuro.
Consegna:
Money con Amount (decimal) e Currency (string).Aggiungi(Money altro) e Sottrai(Money altro) con controllo valuta.Euro(decimal) e Dollaro(decimal).Money(10, "EUR") sono uguali.Obiettivo: implementare un Value Object multi-proprietà con operazioni di dominio.