Domain Feature Documentation¶
Build domain models with the core tactical patterns of DDD, from aggregates to typed ids and value objects.
Overview¶
The Domain feature is the foundation for the devkit's domain model. It covers the core tactical building blocks used to express business concepts in code, such as aggregates, value objects, typed ids, enumerations, and fluent aggregate state changes.
This page focuses on those core primitives. Related domain concepts have their own dedicated feature pages:
- Domain Events for aggregate-raised events and the domain-event outbox
- Event Sourcing for aggregate-event streams and event-store-based modeling
- Domain Specifications for reusable query and selection criteria
- Domain Policies for contextual business decisions over a domain context
- Domain Repositories for repository abstractions, find options, includes, and behaviors
- ActiveEntity for the optional active-record-style alternative
Typed ids and smart enumerations from the domain layer also integrate with the shared serializer infrastructure described in Common Serialization.
Challenges¶
Solution¶
Use Cases¶
Appendix A: Smart Enumerations¶
Overview¶
In domain modeling, representing a fixed set of options or states is a common requirement. While C# provides the enum type for this purpose, it often proves too limiting for real-world domain models. The Smart Enumeration pattern offers a more powerful alternative that combines the simplicity of enums with the flexibility of full-fledged objects.
Challenge¶
Traditional C# enums work well for simple flags or states but fall short when requirements grow:
- Cannot include additional data like descriptions or metadata
- No support for business rules or behavior
- Limited to numeric values
- Hard to extend or version
- No validation beyond basic type checking
Solution: Smart Enumerations¶
sequenceDiagram
participant Code as Domain Code
participant Enum as Smart Enumeration
participant DB as Database
Code->>Enum: Create TodoItem with Status
Note over Enum: Rich domain object<br/>with properties & behavior
Code->>Enum: Access Description
Enum-->>Code: "Task is in progress"
Code->>DB: Save TodoItem
Note over DB: Stores simple ID (2)
DB-->>Code: Load TodoItem
Note over Enum: Converts back to<br/>rich object
Usage¶
public class TodoStatus : Enumeration
{
public static readonly TodoStatus New = new(1, nameof(New), "Newly created task");
public static readonly TodoStatus InProgress = new(2, nameof(InProgress), "Task is being worked on");
public static readonly TodoStatus Completed = new(3, nameof(Completed), "Task has been completed");
private TodoStatus(int id, string value, string description)
: base(id, value)
{
this.Description = description;
}
public string Description { get; }
public static IEnumerable<TodoStatus> GetAll() => GetAll<TodoStatus>();
}
Entity Framework Configuration¶
public class TodoItemEntityTypeConfiguration : IEntityTypeConfiguration<TodoItem>
{
public void Configure(EntityTypeBuilder<TodoItem> builder)
{
builder.Property(x => x.Status)
.HasConversion(new EnumerationConverter<TodoStatus>())
.IsRequired();
}
}
Benefits¶
Smart Enumerations transform enumerated values from simple flags into rich domain concepts. They shine in real applications by providing:
-
Rich Domain Expression - Instead of bare numbers, enumerations carry meaning through additional properties and behavior. When a developer looks at a todo item's status, they see not just a value but also its description, business rules, and any metadata.
-
Natural Evolution - As applications grow, enumerations often need additional properties or behaviors. Smart Enumerations accommodate this growth naturally - adding a new status property or validation rule doesn't break existing code.
-
Safety with Simplicity - While providing rich domain features, they maintain the simplicity of traditional enums in usage. The type system prevents errors like assigning a priority to a status field, while Entity Framework Core's value converters ensure clean persistence.
The result is code that better expresses business concepts while remaining maintainable and safe - a perfect fit for domain-driven applications that need to evolve over time.
Appendix B: Strongly-Typed Entity IDs¶
Overview¶
In domain-driven design and clean architecture, entity identifiers play a crucial role. However, using primitive types like Guid or int as identifiers can lead to subtle bugs and unclear code. Consider a system managing both Todos and TodoSteps, each using GUIDs as identifiers. A method that accidentally accepts a TodoStep ID when it should work with Todo IDs will compile successfully because both are GUIDs.
This common anti-pattern is known as "primitive obsession" - using primitive types where a dedicated type would better express domain concepts and prevent errors.
Solution¶
The TypedEntityId source generator automatically creates strongly-typed ID classes during compilation. It scans for classes marked with the [TypedEntityId<T>] attribute and generates corresponding ID wrapper classes that provide type safety and domain semantics.
sequenceDiagram
participant Dev as Developer
participant CS as Compiler
participant Gen as Generator
participant App as Application
Dev->>CS: Compile code with [TypedEntityId<T>]
CS->>Gen: Trigger source generation
Note over Gen: Find classes with TypedEntityId<T>
Note over Gen: Extract T as underlying type
Note over Gen: Generate ID class with:<br/>- Constructors<br/>- Value property of type T<br/>- JSON conversion<br/>- Equality methods
Gen->>CS: Add generated code
CS->>App: Compile final assembly
Features (Generator)¶
The source generator creates ID classes with:
- Value wrapping and access
- Type conversions
- JSON serialization support
- Equality comparison
- Debug visualization
- Factory methods for creation
- Null handling
Usage¶
Domain Entity¶
[TypedEntityId<Guid>] // triggers the generator
public class TodoItem : Entity<TodoId> // generated id
{
public string Title { get; set; }
//...
}
Application Code¶
// Type safety prevents mixing different ID types
public async Task<Todo> GetTodo(TodoId id) // ✅
public async Task<Todo> GetTodo(TodoStepId id) // ❌ Won't compile
// Convenient implicit conversions
TodoItemId id = Guid.NewGuid(); // Guid to TodoItemId
Guid guid = id; // TodoItemId to Guid
Entity Framework Configuration¶
The strongly-typed IDs require proper Entity Framework configuration to map between domain types and database primitives:
public class TodoItemEntityTypeConfiguration : IEntityTypeConfiguration<Todo>
{
public void Configure(EntityTypeBuilder<TodoItem> builder)
{
builder.Property(e => e.Id).ValueGeneratedOnAdd()
.HasConversion(
id => id.Value, // To database: TodoId -> Guid
value => TodoItemId.Create(value)); // From database: Guid -> TodoId
// Navigation property configuration
builder.OwnsMany(x => x.Steps, sb =>
{
sb.Property(s => s.Id).ValueGeneratedOnAdd()
.HasConversion(id => id.Value, value => TodoStepId.Create(value));
});
}
}
Benefits¶
- Type Safety: Compiler catches ID type mismatches
- Domain Clarity: IDs carry semantic meaning
- Convenience: Implicit conversions to/from primitive types
- Debugging: Meaningful string representation
- JSON Support: Built-in serialization handling
- Persistence: Seamless Entity Framework integration
- Value Semantics: Proper equality comparison
The TypedEntityId pattern transforms primitive identifiers into first-class domain concepts, making code both safer and more expressive. It prevents a whole class of bugs while better communicating domain intent through the type system.
Appendix C: Fluent Aggregate Updates¶
Overview¶
In Domain-Driven Design, Aggregate Roots are responsible for maintaining consistency boundaries. Modifying state often involves complex logic:
- Change Tracking: Only applying updates if the value actually changed.
- Event Sourcing: Raising Domain Events when specific state changes occur.
- Invariants: Ensuring business rules (guards) are met before and after changes.
- Side Effects: Handling interactions with child entities.
Implementing this logic manually in every setter or update method leads to repetitive, error-prone boilerplate code (the "check-change-notify" pattern).
Challenge¶
Writing consistent update logic for every property is tedious. Developers often forget to check if the value actually changed before raising an event, or they duplicate validation logic.
Anti-Pattern (Manual Implementation):
public void ChangeEmail(string newEmail)
{
if (string.IsNullOrEmpty(newEmail)) throw new ArgumentException(...); // Guard
if (this.Email != newEmail) // Check difference
{
var oldEmail = this.Email;
this.Email = newEmail; // Set
// Notify
this.DomainEvents.Register(new EmailChangedEvent(oldEmail, newEmail));
this.DomainEvents.Register(new CustomerUpdatedEvent(this));
}
}
Solution: Fluent Change Builder¶
The AggregateRoot extensions provide a fluent, transactional builder pattern (this.Change()) to handle state mutations declaratively. It encapsulates the complexity of change detection, validation, and event registration into a clean, readable API.
Key Feature: Declaration-Order Execution
All operations execute in the exact order they are declared. This makes the code intuitive and predictable - "what you see is what executes":
return this.Change()
.Set(c => c.Name, "John") // 1. Executes first
.Check(c => c.Name != null, "") // 2. Validates immediately after Set
.When(c => c.Age >= 18) // 3. Circuit breaker - cancels remaining if false
.Set(c => c.Status, Adult) // 4. Only executes if When succeeded
.Register(c => new Event()) // 5. Queues event if changes occurred
.Apply();
sequenceDiagram
participant Dev as Developer
participant Builder as ChangeBuilder
participant Agg as AggregateRoot
participant Events as DomainEvents
Dev->>Builder: this.Change()<br/>.Set(Property1)<br/>.Check(Rule1)<br/>.When(Guard)<br/>.Set(Property2)<br/>.Register(Event)<br/>.Apply()
Note over Builder: Execute in declaration order
Builder->>Agg: Set Property1
Builder->>Builder: Check Rule1 (immediate)
alt When Guard Passes
Builder->>Agg: Set Property2
Builder->>Builder: Queue Event
Builder->>Events: Register Events at Apply() end
Builder-->>Dev: Success Result
else When Guard Fails (Circuit Breaker)
Note over Builder: Skip remaining operations<br/>Property1 changed, Property2 unchanged
Builder->>Events: Register events for changes before When
Builder-->>Dev: Success Result (partial changes)
end
Builder-->>Dev: Return Success Result
else If Invalid or No Change
Builder-->>Dev: Return Failure or Success(NoOp)
end
Usage¶
Basic Property Update¶
public Result<Customer> ChangeName(string firstName, string lastName)
{
return this.Change()
.Set(c => c.FirstName, firstName)
.Set(c => c.LastName, lastName)
.Regisiter(c => new CustomerNameChangedEvent(c.Id))
.Apply();
}
Conditional Logic with When (Circuit Breaker)¶
The When method acts as a circuit breaker at its declared position. Operations before When execute normally, operations after When only execute if the condition is true.
public Result<Customer> PromoteToVIP()
{
return this.Change()
.Set(c => c.LastReviewed, DateTime.UtcNow) // Always executes
.When(c => c.TotalSpend > 1000) // Circuit breaker - only proceed if true
.Set(c => c.Status, CustomerStatus.VIP) // Only if When passed
.Check(c => c.HasValidEmail(), "VIPs must have valid email") // Immediate validation
.Register(c => new CustomerPromotedEvent(c.Id))
.Apply();
}
Important: If When fails, LastReviewed is still updated, but Status remains unchanged and no promotion event is registered. This allows for partial updates with conditional logic.
Side Effects with OnChanged¶
The OnChanged method queues actions that execute only if changes occurred, useful for side effects like audit updates or logging.
public Result<Customer> ChangeStatus(CustomerStatus status)
{
return this.Change()
.When(_ => status != null)
.Set(e => e.Status, status)
.Register(e => new CustomerUpdatedDomainEvent(e))
.OnChanged(e => e.AuditState.SetUpdated()) // Executes only if changes occurred
.Apply();
}
Validation with Check and Ensure¶
Ensure: Pre-condition check - aborts before making changes if falseCheck: Post-condition validation - executes immediately at its position, after changes
public Result<Customer> UpdateProfile(string name, int age)
{
return this.Change()
.Ensure(c => c.IsActive, "Cannot update inactive customer") // Pre-check
.Set(c => c.Name, name)
.Check(c => !string.IsNullOrEmpty(c.Name), "Name required") // Validates immediately
.Set(c => c.Age, age)
.Check(c => c.Age >= 0, "Age must be positive") // Validates immediately
.Apply();
}
Using Result-Returning Factories (Fail Fast)¶
If the value generation itself can fail (e.g., creating a Value Object), the builder handles the Result automatically. If EmailAddress.Create returns a Failure, the chain stops, and Apply() returns that failure.
public Result<Customer> ChangeEmail(string emailString)
{
return this.Change()
// If Create returns Failure, the chain aborts here
.Set(c => c.Email, EmailAddress.Create(emailString))
.Regisiter((c, ctx) => new EmailChangedEvent(
ctx.GetOldValue<EmailAddress>(nameof(Email)),
c.Email))
.Apply();
}
Collection Management¶
// Add/Remove items
public Result<Customer> AddTag(string tag)
{
return this.Change()
.Add(c => c.Tags, tag)
.Ensure(c => c.Tags.Count < 10, "Tag limit reached")
.Apply();
}
// Remove by ID (fails with NotFoundError if not found)
public Result<Customer> RemoveAddress(AddressId addressId)
{
return this.Change()
.Remove(c => c.Addresses, addressId, errorMessage: "Address not found")
.Register(c => new CustomerUpdatedEvent(c.Id))
.Apply();
}
// Apply action to all collection items
public Result<Customer> ClearAllPrimaryFlags()
{
return this.Change()
.Set(c => c.Addresses, a => a.ClearPrimary()) // Applies to all
.Apply();
}
// Apply action to filtered items
public Result<Customer> ActivateExpiredSubscriptions()
{
return this.Change()
.Set(c => c.Subscriptions, s => s.IsExpired, s => s.Renew()) // Filter + action
.Apply();
}
// Apply action to single item by ID (fails with NotFoundError if not found)
public Result<Customer> SetPrimaryAddress(AddressId addressId)
{
return this.Change()
.Set(c => c.Addresses, a => a.ClearPrimary()) // Clear all
.Set(c => c.Addresses, addressId, a => a.SetPrimary(), "Address not found") // Set one
.Register(c => new CustomerUpdatedEvent(c.Id))
.Apply();
}
Executing Methods with Result Propagation¶
When you need to call other domain methods that return Result, use Set to chain them. If any method fails, the entire chain stops and returns that failure.
public Result<Customer> UpdateContactInfo(string firstName, string lastName, int age, string email)
{
return this.Change()
.Set(c => c.ChangeName(firstName, lastName)) // If fails, chain stops
.Set(c => c.ChangeAge(age)) // Only runs if previous succeeded
.Set(c => c.ChangeEmail(email)) // Only runs if previous succeeded
.Apply();
}
// Individual methods that return Results
public Result<Customer> ChangeName(string firstName, string lastName)
{
return this.Change()
.Set(c => c.FirstName, firstName)
.Set(c => c.LastName, lastName)
.Check(c => !string.IsNullOrEmpty(c.FirstName), "First name required")
.Apply();
}
For void actions (like clearing collections), use Execute. If the action throws an exception, it's automatically caught and the chain stops with a failure:
public Result<Customer> ResetData()
{
return this.Change()
.Execute(c => c.Tags.Clear()) // If throws exception, chain stops with failure
.Execute(c => c.Notes.Clear())
.Apply();
}
Result Transformations with Execute¶
The Execute method can also be used to apply Result functional extensions (Map, Bind, Tap, Ensure, Filter, etc.) after all operations complete. This enables powerful post-processing, validation, and side effects while maintaining the Result pattern:
public Result<Customer> PromoteToAdult()
{
return this.Change()
.When(c => c.Age >= 18) // Only proceed if eligible
.Set(c => c.Status, CustomerStatus.Adult)
.Execute(r => r.Map(c => { c.PromotedDate = DateTime.UtcNow; return c; })) // Additional field update
.Execute(r => r.Ensure(
c => !string.IsNullOrEmpty(c.Email),
new ValidationError("Adults must have an email"))) // Post-operation validation
.Execute(r => r.Tap(c => logger.LogInformation($"Promoted {c.Name} to Adult"))) // Logging
.Apply();
}
Key behaviors:
Executetransformations execute at their declared position in the operation chain- Multiple
Executecalls execute sequentially in declaration order - If any
Executetransformation returns a failure Result, remaining operations are short-circuited Executetransformations skip when a precedingWhencircuit breaker cancels remaining operations- Can be used standalone without any Set/Add operations:
.Change().Execute(r => r.Tap(...)).Apply()
Execution order example:
return this.Change()
.Set(c => c.Field1, "A") // 1. Executes
.Execute(r => r.Tap(c => Log("A"))) // 2. Logs "A"
.Set(c => c.Field2, "B") // 3. Executes
.Execute(r => r.Tap(c => Log("B"))) // 4. Logs "B"
.Apply();
Common use cases:
- Logging: Use
.Execute(r => r.Tap(...))for side effects without changing the value - Additional validation: Use
.Execute(r => r.Ensure(...))for complex post-operation checks - Transformations: Use
.Execute(r => r.Map(...))to modify additional fields based on the final state - Conditional logic: Use
.Execute(r => r.Filter(...))to convert success to failure based on conditions
// Standalone usage - no Set required
public Result<Customer> LogActivity()
{
return this.Change()
.Execute(r => r.Tap(c => activityLogger.Log($"Activity for {c.Name}")))
.Execute(r => r.Ensure(c => c.IsActive, new Error("Customer is not active")))
.Apply();
}
// Multiple Execute calls with validation
public Result<Customer> ComplexUpdate(string name, int age)
{
return this.Change()
.Set(c => c.Name, name)
.Set(c => c.Age, age)
.Execute(r => r.Ensure(c => c.Age >= 18, new ValidationError("Must be adult")))
.Execute(r => r.Map(c => { c.LastModified = DateTime.UtcNow; return c; }))
.Execute(r => r.Tap(c => auditLog.Record($"Updated {c.Name}")))
.Apply();
}
Features¶
| Operation | Description |
|---|---|
Set |
Updates a property at its declaration position. Supports direct values, computed factories, Result<T> factories (fail-fast), and Result-returning methods for chaining domain logic. Only updates if value differs (automatic change detection). Also applies actions to collection items: all items, filtered items, or single item by ID. |
Add / Remove / Clear |
Manages collection properties with automatic change detection. Remove fails with NotFoundError if item not found. Executes at declaration position. |
Ensure |
Pre-condition guard that executes before making changes. If false, aborts transaction immediately. Executes at declaration position. |
Check |
Post-condition validation that executes immediately at its position after preceding operations. If false, returns Failure result. Use for immediate validation after specific changes. |
When |
Circuit breaker that executes at its declaration position. If condition is false, cancels all remaining operations after it. Operations before When execute normally. Enables conditional operation chains. |
Execute |
Two overloads: (1) Runs arbitrary void actions at declaration position with automatic exception handling. (2) Applies Result transformations (Map, Bind, Tap, Ensure) at declaration position. Both short-circuit on failure. |
Register |
Queues a Domain Event at declaration position to be registered at Apply() end if changes occurred. Provides access to ChangeContext for old values. Events only register if changes made and no cancellation. |
OnChanged |
Queues an action at declaration position to be executed at Apply() end if changes occurred. Actions run on the entity in declaration order. Exceptions in actions cause Apply() to return failure. |
Apply |
Executes all queued operations in declaration order, registers queued events and executes OnChanged actions (if changes occurred), and returns a Result. |
Execution Model¶
Declaration Order Guarantee:
- All operations execute in the exact order they are declared
- No batching or phase-based execution
- "What you see is what executes"
When as Circuit Breaker:
.Set(prop1) // Always executes
.Register(event1) // Always queues
.OnChanged(action1) // Always queues
.When(condition) // Decision point
.Set(prop2) // Skips if When false
.Register(event2) // Skips if When false
.OnChanged(action2) // Skips if When false
Check Executes Immediately:
.Set(c => c.Age, 25)
.Check(c => c.Age > 0, "Age must be positive") // Validates immediately after Set
.Set(c => c.Name, "John") // Only executes if Check passed
Benefits¶
- Declarative Syntax: Reads like a sentence describing the business transaction.
- Automatic Change Detection: Properties are only updated if values actually differ; events are only raised if updates occurred.
- Consistency: Enforces a standard pattern for all aggregate updates.
- Reduced Boilerplate: Removes repetitive
if (old != new)checks and event registration code. - Fail-Fast Safety: Integrates seamlessly with the
Resultpattern to abort operations on validation errors. - Context Awareness: Easy access to "Old Value" vs "New Value" when creating domain events.