// src/Bookify.Domain/Shared/Money.cs:3public 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);}
// src/Bookify.Domain/Bookings/ValueObjects/DateRange.cs:3public 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 }; }}
public class Booking{ public Guid Id { get; set; } public BookingStatus Status { get; set; } // Just getters and setters}// Logic in service layerpublic class BookingService{ public void Confirm(Booking booking) { booking.Status = BookingStatus.Confirmed; }}
Rich Model (Good)
public class Booking : Entity{ public BookingStatus Status { get; private set; } public Result Confirm(DateTime utcNow) { if (Status != BookingStatus.Reserved) { return Result.Failure( BookingErrors.NotReserved); } Status = BookingStatus.Confirmed; ConfirmedOnUtc = utcNow; RaiseDomainEvent( new BookingConfirmedDomainEvent(Id)); return Result.Success(); }}
Bookify uses the Result pattern instead of exceptions for business rule violations:
// src/Bookify.Domain/Abstractions/Result.cs:5public class Result{ public bool IsSuccess { get; } public bool IsFailure => !IsSuccess; public Error Error { get; } public static Result Success() => new(true, Error.None); public static Result Failure(Error error) => new(false, error);}
Errors are defined as static properties:
// src/Bookify.Domain/Bookings/BookingErrors.cspublic static class BookingErrors{ public static Error NotFound = new( "Booking.NotFound", "The booking with the specified identifier was not found"); public static Error Overlap = new( "Booking.Overlap", "The booking is overlapping with an existing booking"); public static Error NotReserved = new( "Booking.NotReserved", "The booking is not pending"); public static Error NotConfirmed = new( "Booking.NotConfirmed", "The booking is not confirmed"); public static Error AlreadyStarted = new( "Booking.AlreadyStarted", "The booking has already started");}
Usage:
var result = booking.Confirm(DateTime.UtcNow);if (result.IsFailure){ // Handle error return BadRequest(result.Error);}// Continue with success path
Always validate in the domain. Never rely solely on API-level validation.
Never expose public setters on entities. Use methods to change state.
When to use a Value Object vs a primitive?
Use a Value Object when:
The value has validation rules (Email, Rating)
The value has behavior (Money, DateRange)
You want type safety (UserId vs ApartmentId both being Guids)
The concept is important to the domain (Address vs just strings)
When to create a new aggregate?
Create a new aggregate when:
The entity has its own lifecycle
It needs to enforce its own invariants
It’s referenced independently from other aggregates
It has clear transactional boundaries
Should aggregates reference other aggregates?
Aggregates should reference other aggregates by ID only, not by navigation properties. This maintains clear boundaries:
// Goodpublic class Booking{ public Guid ApartmentId { get; private set; } public Guid UserId { get; private set; }}// Badpublic class Booking{ public Apartment Apartment { get; private set; } public User User { get; private set; }}
public class BookingTests{ [Fact] public void Confirm_ShouldSucceed_WhenBookingIsReserved() { // Arrange var booking = BookingData.CreateReserved(); var utcNow = DateTime.UtcNow; // Act var result = booking.Confirm(utcNow); // Assert result.IsSuccess.Should().BeTrue(); booking.Status.Should().Be(BookingStatus.Confirmed); booking.ConfirmedOnUtc.Should().Be(utcNow); } [Fact] public void Confirm_ShouldFail_WhenBookingIsNotReserved() { // Arrange var booking = BookingData.CreateConfirmed(); // Act var result = booking.Confirm(DateTime.UtcNow); // Assert result.IsFailure.Should().BeTrue(); result.Error.Should().Be(BookingErrors.NotReserved); } [Fact] public void Cancel_ShouldFail_WhenBookingAlreadyStarted() { // Arrange var booking = BookingData.CreateConfirmedInPast(); // Act var result = booking.Cancel(DateTime.UtcNow); // Assert result.IsFailure.Should().BeTrue(); result.Error.Should().Be(BookingErrors.AlreadyStarted); }}
No mocking required - just pure business logic testing.