Learn how Bookify uses value objects to model domain concepts with built-in validation and type safety
Value objects are immutable types that represent domain concepts through their attributes rather than a unique identity. They encapsulate validation logic and prevent primitive obsession.
public class Booking{ public decimal PriceAmount { get; set; } public string PriceCurrency { get; set; } public string UserEmail { get; set; } // Validation scattered everywhere // No compile-time safety // Easy to mix up parameters}
Problems:
Email validation logic duplicated across the codebase
public class Booking{ public Money Price { get; set; } public Email UserEmail { get; set; } // Validation centralized in value objects // Type-safe // Self-documenting}
Benefits:
Type safety: Can’t pass Email where Money is expected
Centralized validation: Email validation in one place
Self-documenting: Money is clearer than decimal
Business logic encapsulation: Operations like currency conversion live in Money
Represents monetary amounts with currency information:
src/Bookify.Domain/Shared/Money.cs
public record Money(decimal Amount, Currency Currency){ public static Money operator +(Money first, Money second) { if (first.Currency != second.Currency) { throw new InvalidOperationException("Currencies have to be equal"); } return new Money(first.Amount + second.Amount, first.Currency); } public static Money Zero() => new(0, Currency.None); public static Money Zero(Currency currency) => new(0, currency); public bool IsZero() => this == Zero(Currency);}
The Money value object prevents currency mismatch errors at runtime and provides domain-specific operations like addition with currency validation.
public record DateRange{ private DateRange() { } public DateOnly Start { get; init; } public DateOnly End { get; init; } public int LengthInDays => End.DayNumber - Start.DayNumber; public static DateRange Create(DateOnly start, DateOnly end) { if (start > end) { throw new ApplicationException("End date precedes start date"); } return new DateRange { Start = start, End = end }; }}
Represents a validated review rating using the Result pattern:
src/Bookify.Domain/Reviews/ValueObjects/Rating.cs
public sealed record Rating{ public static readonly Error Invalid = new("Rating.Invalid", "The rating is invalid"); private Rating(int value) => Value = value; public int Value { get; init; } public static Result<Rating> Create(int value) { if (value < 1 || value > 5) { return Result.Failure<Rating>(Invalid); } return new Rating(value); }}
Rating uses a private constructor and factory method to ensure only valid ratings (1-5) can be created. It returns Result<Rating> for explicit error handling.
public record DateRange{ private DateRange() { } // Private constructor prevents invalid creation public DateOnly Start { get; init; } public DateOnly End { get; init; } public static DateRange Create(DateOnly start, DateOnly end) { if (start > end) { throw new ApplicationException("End date precedes start date"); } return new DateRange { Start = start, End = end }; }}
public sealed record Rating{ public static readonly Error Invalid = new("Rating.Invalid", "The rating is invalid"); private Rating(int value) => Value = value; public int Value { get; init; } public static Result<Rating> Create(int value) { if (value < 1 || value > 5) { return Result.Failure<Rating>(Invalid); } return new Rating(value); }}
public record Money(decimal Amount, Currency Currency){ // Addition operator with currency validation public static Money operator +(Money first, Money second) { if (first.Currency != second.Currency) { throw new InvalidOperationException("Currencies have to be equal"); } return new Money(first.Amount + second.Amount, first.Currency); } // Domain-specific factory methods public static Money Zero() => new(0, Currency.None); public static Money Zero(Currency currency) => new(0, currency); // Domain queries public bool IsZero() => this == Zero(Currency);}
public record Money(decimal Amount, Currency Currency); // Immutable by defaultpublic record DateRange{ public DateOnly Start { get; init; } // Can only set during initialization public DateOnly End { get; init; }}
Validate in constructor or factory
Ensure invalid objects cannot be created:
public static Result<Rating> Create(int value){ if (value < 1 || value > 5) { return Result.Failure<Rating>(Invalid); } return new Rating(value);}
Use private constructors
Force creation through factory methods:
private Rating(int value) => Value = value; // Can't call directlypublic static Result<Rating> Create(int value) { } // Must use factory
Implement domain operations
Put logic where it belongs:
public static Money operator +(Money first, Money second){ if (first.Currency != second.Currency) throw new InvalidOperationException("Currencies have to be equal"); return new Money(first.Amount + second.Amount, first.Currency);}