Skip to main content
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.

What Are Value Objects?

In Domain-Driven Design, a value object is defined by its attributes, not by an identity:
  • No identity: Two value objects with the same attributes are considered equal
  • Immutable: Once created, their state cannot change
  • Self-validating: Validation happens in the constructor, ensuring only valid objects exist
  • Behavior-rich: Contain domain logic related to the concept they represent
Value objects solve primitive obsession—the anti-pattern of using primitive types (string, int, decimal) to represent domain concepts.

Why Use Value Objects?

Without Value Objects (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
  • Can mix up currency codes
  • No type safety (can pass any string for email)
  • Business rules scattered everywhere

With Value Objects

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

Value Objects in Bookify

Money - Financial Values

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.
Used for:
  • Booking.PriceForPeriod
  • Booking.CleaningFee
  • Booking.AmenitiesUpCharge
  • Booking.TotalPrice
  • Apartment.Price
  • Apartment.CleaningFee

DateRange - Periods

Represents a period between two dates with validation:
src/Bookify.Domain/Bookings/ValueObjects/DateRange.cs
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
        };
    }
}
Features:
  • Factory method ensures valid date ranges
  • Computed property for duration
  • Encapsulates date range logic
Used for:
  • Booking.Duration

Rating - Review Scores

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.
Used for:
  • Review.Rating

Address - Location Information

Represents a physical address:
src/Bookify.Domain/Apartments/ValueObjects/Address.cs
public record Address(
    string Country,
    string State,
    string City,
    string ZipCode,
    string Street);
Features:
  • Immutable record
  • Structural equality (two addresses with same values are equal)
  • Groups related properties
Used for:
  • Apartment.Address

Email - User Email Addresses

src/Bookify.Domain/Users/ValueObjects/Email.cs
public record Email(string Value);
Used for:
  • User.Email

FirstName and LastName - User Names

src/Bookify.Domain/Users/ValueObjects/FirstName.cs
public record FirstName(string Value);
src/Bookify.Domain/Users/ValueObjects/LastName.cs
public record LastName(string Value);
Used for:
  • User.FirstName
  • User.LastName

Comment - Review Text

src/Bookify.Domain/Reviews/ValueObjects/Comment.cs
public sealed record Comment(string Value);
Used for:
  • Review.Comment

Implementing Value Objects

Using C# Records

Bookify uses C# records for value objects because they provide:
  1. Immutability by default: Records encourage immutable design
  2. Structural equality: Automatic value-based equality
  3. Concise syntax: Less boilerplate code
  4. Deconstruction: Easy to extract values

Pattern 1: Simple Value Object

For straightforward value objects without complex validation:
public record Email(string Value);

public record Address(
    string Country,
    string State,
    string City,
    string ZipCode,
    string Street);

Pattern 2: Factory Method with Validation

For value objects requiring validation:
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 };
    }
}

Pattern 3: Factory Method with Result

For functional error handling:
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);
    }
}

Pattern 4: Rich Behavior

Value objects can contain domain operations:
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);
}

Value Objects vs Entities

AspectValue ObjectEntity
IdentityDefined by attributesHas unique ID
EqualityStructural (all properties match)Identity-based (same ID)
MutabilityImmutableMutable
LifespanCreated and discardedTracked throughout lifecycle
ExamplesMoney, DateRange, AddressBooking, User, Apartment
var money1 = new Money(100, Currency.Usd);
var money2 = new Money(100, Currency.Usd);

money1 == money2; // true - same values

When to Use Value Objects

Use value objects when:

No Identity Needed

The concept is defined by its attributes, not an ID. Example: Money, Address.

Validation Required

You need to enforce business rules. Example: Rating must be 1-5.

Encapsulating Logic

Domain operations belong to the concept. Example: Money addition.

Type Safety

You want compile-time safety. Example: Can’t mix Email with FirstName.

Best Practices

Use readonly properties or init-only setters:
public record Money(decimal Amount, Currency Currency); // Immutable by default

public record DateRange
{
    public DateOnly Start { get; init; } // Can only set during initialization
    public DateOnly End { get; init; }
}
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);
}
Force creation through factory methods:
private Rating(int value) => Value = value; // Can't call directly

public static Result<Rating> Create(int value) { } // Must use factory
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);
}
Don’t use strings/ints for domain concepts:
// Bad
public void SendEmail(string email) { }

// Good
public void SendEmail(Email email) { }

Persistence Considerations

Value objects are typically stored as part of their owning entity:
Entity Framework Configuration
public class BookingConfiguration : IEntityTypeConfiguration<Booking>
{
    public void Configure(EntityTypeBuilder<Booking> builder)
    {
        // Money as owned entity
        builder.OwnsOne(b => b.PriceForPeriod, priceBuilder =>
        {
            priceBuilder.Property(m => m.Amount).HasColumnName("PriceAmount");
            priceBuilder.Property(m => m.Currency).HasColumnName("PriceCurrency");
        });

        // DateRange as owned entity
        builder.OwnsOne(b => b.Duration, durationBuilder =>
        {
            durationBuilder.Property(d => d.Start).HasColumnName("DurationStart");
            durationBuilder.Property(d => d.End).HasColumnName("DurationEnd");
        });
    }
}

Next Steps

Learn how aggregates establish consistency boundaries and enforce invariants

Build docs developers (and LLMs) love