Base and Value Objects Reference
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:
- Identity-less - Defined by attributes, not identity
- Immutable - Once created, values do not change
- Replaceable - Swap one instance for another with same values
- 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:
- Identity Confusion - Value Objects are identity-less, but Entities have identity
- Lifecycle Mismatch - Entity lifecycle (create, update, delete) does not fit Value Object semantics
- Persistence Complexity - How would the nested Entity be saved?
- 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();
Related Topics
- ValidateBase Reference - Validation without persistence
- EntityBase Reference - Full entity with persistence
- DDD Concepts - Domain-Driven Design patterns
- Factory Operations Reference - Factory system
- Data Mapping Reference - Mapping patterns