Value Objects in Domain-Driven Design represent concepts defined entirely by their attributes, with no distinct identity. In Neatoo, Value Objects are implemented using simple classes decorated with the [Factory] attribute.

The DDD Value Object Pattern

In Domain-Driven Design, a Value Object represents a concept defined entirely by its attributes, with no distinct identity. Two Value Objects with the same attributes are considered equal, regardless of which instance you hold.

Classic Examples:

  • Money - $20 is $20, regardless of which specific bill
  • Address - Two addresses are equal if street, city, and zip match
  • DateRange - A period defined by start and end dates
  • Coordinates - A point defined by latitude and longitude

Characteristics of Value Objects:

  1. Identity-less - Defined by attributes, not identity
  2. Immutable - Once created, values do not change
  3. Replaceable - Swap one instance for another with same values
  4. Self-validating - Invalid values cannot be constructed

Why Value Objects Matter:

Value Objects encapsulate domain concepts with rich behavior. Instead of passing raw primitives, you pass meaningful types that enforce business rules:

// Primitive obsession - error prone
void SetPrice(decimal amount, string currency);

// Value Object approach - type safe and meaningful
void SetPrice(Money amount);

Creating Value Objects with [Factory]

The simplest approach for Value Objects is to use plain classes with the [Factory] attribute. Neatoo’s source generator creates factory interfaces and implementations for object creation:

[Factory]
public class Address
{
    public string? Street { get; set; }
    public string? City { get; set; }
    public string? State { get; set; }
    public string? ZipCode { get; set; }
    public string? Country { get; set; }

    // Computed property
    public string FormattedAddress =>
        $"{Street}\n{City}, {State} {ZipCode}\n{Country}";

    [Create]
    public void Create(
        string street,
        string city,
        string state,
        string zipCode,
        string country = "USA")
    {
        Street = street;
        City = city;
        State = state;
        ZipCode = zipCode;
        Country = country;
    }
}

Neatoo generates a factory interface and implementation automatically:

// Generated by Neatoo
public interface IAddressFactory
{
    Address Create(string street, string city, string state, string zipCode, string country = "USA");
}

The [Factory] Attribute

The [Factory] attribute marks classes for factory generation. It works on plain classes - no base class inheritance required:

[Factory]
public class Money
{
    public decimal Amount { get; set; }
    public string? Currency { get; set; }

    [Create]
    public void Create(decimal amount, string currency = "USD")
    {
        if (amount < 0)
            throw new ArgumentException("Amount cannot be negative", nameof(amount));
        if (string.IsNullOrEmpty(currency))
            throw new ArgumentException("Currency is required", nameof(currency));

        Amount = amount;
        Currency = currency;
    }
}

Generated Factory:

// Generated by Neatoo
public interface IMoneyFactory
{
    Money Create(decimal amount, string currency = "USD");
    // Authorization methods if applicable
}

Usage:

var money = moneyFactory.Create(99.99m, "USD");

Factory Operations for Value Objects

Value Object factories typically only have [Create] methods:

Factory Method Entity Use Value Object Use
[Create] Initialize new entity Create value object
[Fetch] Load from database Rarely used
[Insert] Persist new entity Not applicable
[Update] Save changes Not applicable
[Delete] Remove from database Not applicable

Value Objects are not persisted independently; they are typically part of their containing entity.

Immutability Patterns

Traditional Value Objects are immutable - once created, their values never change. Neatoo supports patterns to achieve this.

Constructor Validation

Validate in the [Create] method and throw for invalid inputs:

[Create]
public void Create(decimal amount, string currency)
{
    if (amount < 0)
        throw new ArgumentException("Amount cannot be negative");

    if (string.IsNullOrEmpty(currency) || currency.Length != 3)
        throw new ArgumentException("Currency must be 3-letter code");

    Amount = amount;
    Currency = currency.ToUpperInvariant();
}

Private Setters Pattern

Use public getters with private setters to enforce immutability after creation:

[Factory]
public class Money
{
    // Properties with private setters - only settable during creation
    public decimal Amount { get; private set; }
    public string Currency { get; private set; } = "USD";

    // Rich behavior methods return NEW instances
    public Money Add(Money other, IMoneyFactory factory)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("Cannot add different currencies");

        return factory.Create(Amount + other.Amount, Currency);
    }

    public Money Multiply(decimal factor, IMoneyFactory factory)
    {
        return factory.Create(Amount * factor, Currency);
    }

    [Create]
    public void Create(decimal amount, string currency = "USD")
    {
        if (amount < 0)
            throw new ArgumentException("Amount cannot be negative");

        Amount = amount;
        Currency = currency.ToUpperInvariant();
    }
}

Replacement Pattern

When a value changes, replace the entire Value Object:

// In an Entity
public Address? ShippingAddress { get; set; }

// To "change" the address, replace it entirely
public void UpdateShippingCity(string newCity, IAddressFactory addressFactory)
{
    if (ShippingAddress == null) return;

    // Create new address with updated city
    ShippingAddress = addressFactory.Create(
        ShippingAddress.Street,
        newCity,  // Changed
        ShippingAddress.State,
        ShippingAddress.ZipCode,
        ShippingAddress.Country);
}

Value Object Serialization

Neatoo handles Value Object serialization automatically using System.Text.Json with reference preservation and interface support.

Properties Serialize Directly

When an entity contains a Value Object property, it serializes as a nested object:

[Factory]
public class Order
{
    // Value Object property
    public Money? TotalAmount { get; set; }

    public Address? ShippingAddress { get; set; }
}

The JSON representation includes nested objects:

{
  "id": "...",
  "totalAmount": {
    "amount": 150.00,
    "currency": "USD"
  },
  "shippingAddress": {
    "street": "123 Main St",
    "city": "Springfield",
    "state": "IL",
    "zipCode": "62701",
    "country": "USA"
  }
}

Remote Operations

Value Objects serialize correctly across the client-server boundary:

// Client creates Value Object
var address = addressFactory.Create("123 Main", "City", "ST", "12345");
order.ShippingAddress = address;

// When order saves, address serializes to server
await orderFactory.Save(order);
// Server receives the complete Value Object

Using Value Objects with Entities

Value Objects are properties on entities, not independent persistent objects. You can combine Value Objects with EntityBase entities.

Declaration

[Factory]
internal partial class Customer : EntityBase<Customer>, ICustomer
{
    public partial Guid? Id { get; set; }
    public partial string? Name { get; set; }

    // Value Object properties
    public partial Address? BillingAddress { get; set; }
    public partial Address? ShippingAddress { get; set; }
    public partial Money? CreditLimit { get; set; }
}

Factory Integration

Create Value Objects within entity factory methods:

[Create]
public void Create(
    [Service] IAddressFactory addressFactory,
    [Service] IMoneyFactory moneyFactory)
{
    BillingAddress = addressFactory.Create("", "", "", "", "USA");
    ShippingAddress = addressFactory.Create("", "", "", "", "USA");
    CreditLimit = moneyFactory.Create(0, "USD");
}

[Remote]
[Fetch]
public async Task<bool> Fetch(
    [Service] ICustomerDbContext db,
    [Service] IAddressFactory addressFactory,
    [Service] IMoneyFactory moneyFactory)
{
    var entity = await db.Customers.FindAsync(Id);
    if (entity == null) return false;

    // Load simple properties
    LoadProperty(nameof(Id), entity.Id);
    LoadProperty(nameof(Name), entity.Name);
    LoadProperty(nameof(Email), entity.Email);

    // Create Value Objects from stored data
    BillingAddress = addressFactory.Create(
        entity.BillingStreet,
        entity.BillingCity,
        entity.BillingState,
        entity.BillingZip,
        entity.BillingCountry);

    CreditLimit = moneyFactory.Create(
        entity.CreditLimitAmount,
        entity.CreditLimitCurrency);

    return true;
}

Storing Value Objects

Flatten Value Objects when persisting:

[Insert]
public async Task Insert([Service] IDbContext db)
{
    var entity = new CustomerEntity
    {
        Id = Id.Value,
        Name = Name,
        Email = Email
    };

    // Flatten Value Object to columns
    if (BillingAddress != null)
    {
        entity.BillingStreet = BillingAddress.Street;
        entity.BillingCity = BillingAddress.City;
        entity.BillingState = BillingAddress.State;
        entity.BillingZip = BillingAddress.ZipCode;
        entity.BillingCountry = BillingAddress.Country;
    }

    db.Customers.Add(entity);
    await db.SaveChangesAsync();
}

Why Value Objects Should Not Contain Entities

Value Objects should not reference Entities. This is a DDD principle.

The Problem

If a Value Object contained an Entity:

  1. Identity Confusion - Value Objects are identity-less, but Entities have identity
  2. Lifecycle Mismatch - Entity lifecycle (create, update, delete) does not fit Value Object semantics
  3. Persistence Complexity - How would the nested Entity be saved?
  4. Equality Breaks - Two Value Objects with “equal” Entity references may not truly be equal

The Solution

Value Objects contain only:

  • Primitive types (string, int, decimal, DateTime, etc.)
  • Other Value Objects
  • Enums
  • Immutable collections of the above
// Correct: Value Object contains primitives and other Value Objects
[Factory]
public class OrderSummary
{
    public Money? Subtotal { get; set; }      // Value Object
    public Money? Tax { get; set; }           // Value Object
    public Money? Total { get; set; }         // Value Object
    public int ItemCount { get; set; }        // Primitive
    public DateTime CalculatedAt { get; set; } // Primitive
}

// Incorrect: Value Object containing Entity - avoid this
// public Order? Order { get; set; }  // Don't do this!

Reference Entities from Entities

When you need to reference an Entity from a Value Object concept, reference it from the containing Entity instead:

[Factory]
internal partial class OrderLine : EntityBase<OrderLine>, IOrderLine
{
    // Entity reference - on the Entity, not Value Object
    public partial IProduct? Product { get; set; }

    // Value Object for the price snapshot
    public partial Money? UnitPrice { get; set; }

    // Primitive for simple values
    public partial int Quantity { get; set; }
}

C# Records for Value Objects

C# records are a natural fit for Value Objects. Records provide structural equality, immutability by default, and concise syntax - exactly what DDD Value Objects need. Neatoo 10.1.0+ fully supports records with the factory pattern.

Why Records for Value Objects?

Records and Value Objects share the same characteristics:

Characteristic Value Object C# Record
Equality by value ✅ Two VOs with same attributes are equal ✅ Built-in structural equality
Immutability ✅ Values don’t change after creation ✅ Init-only or positional properties
No identity ✅ Defined by attributes, not ID ✅ No inherent identity concept
Self-describing ✅ Type communicates intent ✅ Concise declaration syntax

Basic Record Pattern

Use [Factory] and [Create] on the record declaration:

[Factory]
[Create]
public record Money(decimal Amount, string Currency = "USD");

Neatoo generates the factory interface:

// Generated by Neatoo
public interface IMoneyFactory
{
    Money Create(decimal amount, string currency = "USD");
}

Usage:

var price = moneyFactory.Create(99.99m, "USD");
var discount = moneyFactory.Create(10m);  // Uses default currency

Records with Validation

Add validation in a static factory method:

[Factory]
public record Money(decimal Amount, string Currency)
{
    [Create]
    public static Money Create(decimal amount, string currency = "USD")
    {
        if (amount < 0)
            throw new ArgumentException("Amount cannot be negative", nameof(amount));
        if (string.IsNullOrEmpty(currency) || currency.Length != 3)
            throw new ArgumentException("Currency must be 3-letter ISO code", nameof(currency));

        return new Money(amount, currency.ToUpperInvariant());
    }
}

Records with Service Injection

Use [Service] in record parameters for dependency injection. Services are resolved at creation time but hidden from the factory interface:

[Factory]
[Create]
public record Address(
    string Street,
    string City,
    string State,
    string ZipCode,
    [Service] IAddressValidator validator)
{
    // Validation runs during construction via injected service
    public bool IsValid => validator.Validate(Street, City, State, ZipCode);
}

Generated Factory (services hidden):

public interface IAddressFactory
{
    Address Create(string street, string city, string state, string zipCode);
}

Records with Fetch Operations

Records can have static [Fetch] methods for loading from persistence:

[Factory]
[Create]
public record CustomerSummary(Guid Id, string? Name, string? Email)
{
    [Remote]
    [Fetch]
    public static async Task<CustomerSummary?> Fetch(
        Guid id,
        [Service] IDbContext db)
    {
        var entity = await db.Customers.FindAsync(id);
        return entity is null
            ? null
            : new CustomerSummary(entity.Id, entity.Name, entity.Email);
    }
}

Generated Factory:

public interface ICustomerSummaryFactory
{
    CustomerSummary Create(Guid id, string? name, string? email);
    Task<CustomerSummary?> Fetch(Guid id);
}

Records with Remote Operations

Records fully support [Remote] for server-side execution:

[Factory]
[Create]
public record OrderLookupResult(
    Guid OrderId,
    string? CustomerName,
    decimal Total,
    OrderStatus Status)
{
    [Remote]
    [Fetch]
    public static async Task<OrderLookupResult?> Fetch(
        Guid orderId,
        [Service] IOrderRepository repo)
    {
        return await repo.GetOrderSummaryAsync(orderId);
    }
}

Records serialize correctly through NeatooJsonSerializer across the client-server boundary.

Record Type Constraints

Record Type Supported Notes
record ✅ Yes Fully supported
record class ✅ Yes Same as record
record struct ❌ No Generates diagnostic NF0206

Why no record struct? Neatoo’s factory pattern relies on reference semantics for tracking and serialization. Value type records would break these patterns.

Records vs Classes

Choose based on your needs:

Scenario Recommendation
Simple Value Objects with few properties Records - concise syntax
Value Objects needing rich behavior methods Classes - better for method organization
DTOs and query results Records - ideal for data transfer
Complex validation during construction Either - both support [Create] validation
Mutable properties needed Classes - records are immutable by design

Complete Record Example

Here’s a comprehensive example showing records with validation, behavior methods, and factory integration:

[Factory]
public record Money(decimal Amount, string Currency)
{
    [Create]
    public static Money Create(decimal amount, string currency = "USD")
    {
        if (amount < 0)
            throw new ArgumentException("Amount cannot be negative", nameof(amount));
        if (string.IsNullOrEmpty(currency) || currency.Length != 3)
            throw new ArgumentException("Currency must be 3-letter ISO code", nameof(currency));

        return new Money(amount, currency.ToUpperInvariant());
    }

    // Records can have methods too
    public Money Add(Money other, IMoneyFactory factory)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException($"Cannot add {Currency} to {other.Currency}");
        return factory.Create(Amount + other.Amount, Currency);
    }

    public Money Multiply(decimal factor, IMoneyFactory factory)
    {
        return factory.Create(Amount * factor, Currency);
    }

    public string Format() => Currency switch
    {
        "USD" => Amount.ToString("C", CultureInfo.GetCultureInfo("en-US")),
        "EUR" => Amount.ToString("C", CultureInfo.GetCultureInfo("de-DE")),
        "GBP" => Amount.ToString("C", CultureInfo.GetCultureInfo("en-GB")),
        _ => $"{Amount:N2} {Currency}"
    };
}

Complete Examples

Address Value Object

[Factory]
public class Address
{
    public string? Street { get; private set; }
    public string? Street2 { get; private set; }
    public string? City { get; private set; }
    public string? State { get; private set; }
    public string? ZipCode { get; private set; }
    public string? Country { get; private set; }

    public string FormattedAddress
    {
        get
        {
            var lines = new List<string>();
            if (!string.IsNullOrEmpty(Street)) lines.Add(Street);
            if (!string.IsNullOrEmpty(Street2)) lines.Add(Street2);
            if (!string.IsNullOrEmpty(City) || !string.IsNullOrEmpty(State))
            {
                var cityState = $"{City}, {State} {ZipCode}".Trim(' ', ',');
                lines.Add(cityState);
            }
            if (!string.IsNullOrEmpty(Country)) lines.Add(Country);
            return string.Join("\n", lines);
        }
    }

    public bool IsComplete =>
        !string.IsNullOrEmpty(Street) &&
        !string.IsNullOrEmpty(City) &&
        !string.IsNullOrEmpty(State) &&
        !string.IsNullOrEmpty(ZipCode);

    [Create]
    public void Create(
        string street,
        string city,
        string state,
        string zipCode,
        string? street2 = null,
        string country = "USA")
    {
        Street = street;
        Street2 = street2;
        City = city;
        State = state;
        ZipCode = zipCode;
        Country = country;
    }
}

Money Value Object with Currency

[Factory]
public class Money
{
    public decimal Amount { get; private set; }
    public string Currency { get; private set; } = "USD";

    public Money Add(Money other, IMoneyFactory factory)
    {
        ValidateSameCurrency(other);
        return factory.Create(Amount + other.Amount, Currency);
    }

    public Money Subtract(Money other, IMoneyFactory factory)
    {
        ValidateSameCurrency(other);
        return factory.Create(Amount - other.Amount, Currency);
    }

    public Money Multiply(decimal factor, IMoneyFactory factory)
    {
        return factory.Create(Amount * factor, Currency);
    }

    public Money Round(int decimals, IMoneyFactory factory)
    {
        return factory.Create(Math.Round(Amount, decimals), Currency);
    }

    public string Format()
    {
        return Currency switch
        {
            "USD" => Amount.ToString("C", CultureInfo.GetCultureInfo("en-US")),
            "EUR" => Amount.ToString("C", CultureInfo.GetCultureInfo("de-DE")),
            "GBP" => Amount.ToString("C", CultureInfo.GetCultureInfo("en-GB")),
            _ => $"{Amount:N2} {Currency}"
        };
    }

    private void ValidateSameCurrency(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException(
                $"Cannot combine {Currency} with {other.Currency}");
    }

    [Create]
    public void Create(decimal amount, string currency = "USD")
    {
        if (string.IsNullOrEmpty(currency) || currency.Length != 3)
            throw new ArgumentException("Currency must be 3-letter ISO code");

        Amount = amount;
        Currency = currency.ToUpperInvariant();
    }
}

DateRange Value Object

[Factory]
public class DateRange
{
    public DateTime Start { get; private set; }
    public DateTime End { get; private set; }

    public int DayCount => (End - Start).Days + 1;

    public bool Contains(DateTime date)
    {
        return date >= Start && date <= End;
    }

    public bool Overlaps(DateRange other)
    {
        return Start <= other.End && End >= other.Start;
    }

    public DateRange? Intersect(DateRange other, IDateRangeFactory factory)
    {
        if (!Overlaps(other)) return null;

        var intersectStart = Start > other.Start ? Start : other.Start;
        var intersectEnd = End < other.End ? End : other.End;

        return factory.Create(intersectStart, intersectEnd);
    }

    [Create]
    public void Create(DateTime start, DateTime end)
    {
        if (end < start)
            throw new ArgumentException("End date must be >= start date");

        Start = start.Date;  // Normalize to date only
        End = end.Date;
    }
}

When to Use Base Classes

While simple [Factory]-decorated classes work well for Value Objects, Neatoo provides base classes for more complex scenarios:

Scenario Recommended Approach
Simple Value Objects Plain class with [Factory]
Objects needing validation ValidateBase<T>
Persistable entities EntityBase<T>

For most Value Objects, the simple [Factory] approach without base class inheritance is preferred.

Best Practices

Validate in Factory Methods

Validate in the [Create] method and throw for invalid inputs:

[Create]
public void Create(decimal amount, string currency)
{
    if (amount < 0)
        throw new ArgumentException("Amount must be non-negative");

    if (currency?.Length != 3)
        throw new ArgumentException("Currency must be 3-letter code");

    Amount = amount;
    Currency = currency;
}

Keep Value Objects Small

Value Objects should be small, focused concepts:

// Good: Focused, single concept
public class Money { public decimal Amount { get; } public string Currency { get; } }
public class Address { public string Street { get; } public string City { get; } ... }

// Less good: Too many unrelated concepts
public class OrderDetails { /* 20+ properties */ }

Expose Behavior, Not Just Data

Value Objects should encapsulate behavior:

// Just data
var total = money1.Amount + money2.Amount;

// Rich behavior
var total = money1.Add(money2, moneyFactory);  // Validates currency match

Use Factories, Not Constructors

Always use the generated factory:

// Correct
var money = moneyFactory.Create(100m, "USD");

// Incorrect - bypasses validation
// var money = new Money();