Entity and Aggregate Implementation — Core Patterns
This document covers the core methods for implementing Entities and Aggregates with the Functorium framework. For design principles and concepts, see 06a-aggregate-design.md. For advanced patterns (Cross-Aggregate relationships, supplementary interfaces, practical examples), see 06c-entity-aggregate-advanced.md.
Introduction
Section titled “Introduction”“Which base class should be used to implement the Aggregate Root and child Entities?” “Who is responsible for validation during Entity creation, and how is ORM restoration distinguished?” “How is a business rule violation expressed in a method signature?”
Entity and Aggregate implementation is the core of domain modeling. This document covers patterns needed for actual implementation, from the base class hierarchy provided by the Functorium framework to creation patterns, command methods, and child Entity management.
What You Will Learn
Section titled “What You Will Learn”Through this document, you will learn:
- Entity<TId> and AggregateRoot<TId> class hierarchy — Features and roles provided by base classes
- Ulid-based Entity ID system — Automatic generation of type-safe identifiers via source generators
- Create / CreateFromValidated creation patterns — Separation of new Entity creation and ORM restoration
- Command methods and invariant protection — Expressing business rule violations as types via
Fin<T>return - Child Entity implementation and event publishing — Child management through Aggregate Root and domain events
Prerequisites
Section titled “Prerequisites”A basic understanding of the following concepts is required to understand this document:
- Aggregate Design Principles — Aggregate boundaries and design principles (WHY)
- Value Object Implementation Guide — Value Object implementation patterns
- Error System: Basics and Naming —
Fin<T>and error return patterns
The core of Entity and Aggregate implementation is separation of validation responsibilities. Value Objects guarantee the validity of primitive values, and Entities receive already-validated Value Objects and compose them. Business rule violations are made explicit in the type system via
Fin<T>returns, forcing callers to handle failures.
Summary
Section titled “Summary”Key Commands
Section titled “Key Commands”// Entity ID creation (Ulid-based)var productId = ProductId.New();
// Aggregate Root creation (receives validated VOs directly)var product = Product.Create(name, description, price, stockQuantity);
// Factory for ORM restorationvar product = Product.CreateFromValidated(id, name, ..., createdAt, updatedAt);
// Command method (invariant protection, Fin<T> return)Fin<Unit> result = order.Confirm(updatedBy);
// Domain event publishingAddDomainEvent(new CreatedEvent(Id, customerId, totalAmount));Key Procedures
Section titled “Key Procedures”- Apply
[GenerateEntityId]attribute to generate EntityId source - Inherit from
AggregateRoot<TId>(orEntity<TId>) - Implement
Create()factory method - Receive validated VOs to create Entity + publish domain events - Implement
CreateFromValidated()method - For ORM restoration (no validation) - Implement command methods - Check invariants then return
Fin<T> - Define domain events as nested records and publish on state changes
Key Concepts
Section titled “Key Concepts”| Concept | Description |
|---|---|
| Entity vs AggregateRoot | Entity has ID-based equality, AggregateRoot has transaction boundary + event publishing |
| Create / CreateFromValidated | Create is for new Entity creation (validated), CreateFromValidated is for DB restoration (no validation) |
| Command methods | Returns Fin.Fail on invariant violation, performs state change + event publishing on success |
| Ulid-based ID | Distributed generation, time-ordered, excellent index performance |
Class Hierarchy
Section titled “Class Hierarchy”Class Hierarchy
Section titled “Class Hierarchy”Functorium provides a base class hierarchy for Entity implementation.
IEntity<TId> (interface)+-- TId Id+-- CreateMethodName constant`-- CreateFromValidatedMethodName constant | `-- Entity<TId> (abstract class) +-- Id property (protected init) +-- Equals() / GetHashCode() - ID-based equality +-- == / != operators +-- CreateFromValidation<TEntity, TValue>() helper `-- GetUnproxiedType() - ORM proxy support | `-- AggregateRoot<TId> : IDomainEventDrain +-- DomainEvents (read-only, IHasDomainEvents) +-- AddDomainEvent() (protected) `-- ClearDomainEvents() (IDomainEventDrain)The following summarizes the roles of each layer.
- IEntity<TId>: Interface defining the Entity contract. Includes method name constants for
CreateandCreateFromValidated. - Entity<TId>: Automatically implements ID-based equality (
Equals,GetHashCode,==,!=). Also handles ORM proxy types. - AggregateRoot<TId>: Provides domain event management. Implements
IDomainEventDrain(internal), separating event querying (IHasDomainEvents) from clearing (IDomainEventDrain).
Entity<TId>
Section titled “Entity<TId>”Abstract base class for Entities that provides ID-based equality.
Location: Functorium.Domains.Entities.Entity<TId>
[Serializable]public abstract class Entity<TId> : IEntity<TId>, IEquatable<Entity<TId>> where TId : struct, IEntityId<TId>{ // Unique identifier for the Entity public TId Id { get; protected init; }
// Default constructor (for ORM/serialization) protected Entity();
// Create Entity with specified ID protected Entity(TId id);
// ID-based equality comparison public override bool Equals(object? obj); public bool Equals(Entity<TId>? other); public override int GetHashCode();
// Equality operators public static bool operator ==(Entity<TId>? a, Entity<TId>? b); public static bool operator !=(Entity<TId>? a, Entity<TId>? b);
// Factory helper method public static Fin<TEntity> CreateFromValidation<TEntity, TValue>( Validation<Error, TValue> validation, Func<TValue, TEntity> factory) where TEntity : Entity<TId>;}The items that must be included when implementing an Entity are as follows.
| Item | Description |
|---|---|
[GenerateEntityId] attribute | Auto-generates EntityId |
| Private constructor (for ORM) | Parameterless default constructor + #pragma warning disable CS8618 |
| Private constructor (internal) | Constructor that receives ID |
Create() | Entity creation factory method |
CreateFromValidated() | ORM restoration method |
Entity implementation examples can be found in the Creation Patterns section.
AggregateRoot<TId>
Section titled “AggregateRoot<TId>”Abstract base class for Aggregate Root that provides domain event management.
Location: Functorium.Domains.Entities.AggregateRoot<TId>
public abstract class AggregateRoot<TId> : Entity<TId>, IDomainEventDrain where TId : struct, IEntityId<TId>{ // Domain events list (read-only, IHasDomainEvents) public IReadOnlyList<IDomainEvent> DomainEvents { get; }
// Default constructor (for ORM/serialization) protected AggregateRoot();
// Create Aggregate Root with specified ID protected AggregateRoot(TId id);
// Add domain event protected void AddDomainEvent(IDomainEvent domainEvent);
// Clear all domain events (IDomainEventDrain) public void ClearDomainEvents();}Interface Segregation Principle:
IHasDomainEvents: Read-only contract for the domain layer (allows only event querying)IDomainEventDrain(internal): Infrastructure interface for cleanup after event publishing- Domain events are immutable facts, so the domain contract does not provide individual deletion methods
The key point in the following example is the structure where AddDomainEvent() publishes events and command methods return Fin<Unit> to express invariant violations.
[GenerateEntityId]public class Order : AggregateRoot<OrderId>{ #region Error Types
// State transition violation error type public sealed record InvalidOrderStatusTransition : DomainErrorType.Custom;
#endregion
public Money TotalAmount { get; private set; } // OrderStatus: Smart Enum based on SimpleValueObject<string> (details: section 6c practical example) public OrderStatus Status { get; private set; }
#pragma warning disable CS8618 private Order() { }#pragma warning restore CS8618
private Order(OrderId id, Money totalAmount) : base(id) { TotalAmount = totalAmount; Status = OrderStatus.Pending; }
// Create: Receives already validated Value Objects directly public static Order Create(Money totalAmount) { var id = OrderId.New(); var order = new Order(id, totalAmount); order.AddDomainEvent(new OrderCreatedEvent(id, totalAmount)); return order; }
// State transition — delegates to TransitionTo() to centralize transition rules public Fin<Unit> Confirm() => TransitionTo(OrderStatus.Confirmed, new OrderConfirmedEvent(Id));
private Fin<Unit> TransitionTo(OrderStatus target, DomainEvent domainEvent) { if (!Status.CanTransitionTo(target)) return DomainError.For<Order, string, string>( new InvalidOrderStatusTransition(), value1: Status, value2: target, message: $"Cannot transition from '{Status}' to '{target}'");
Status = target; AddDomainEvent(domainEvent); return unit; }}Supplementary Interface Summary
Section titled “Supplementary Interface Summary”These are supplementary interfaces mixed into Aggregates/Entities. For detailed implementation and usage examples, see 06c-entity-aggregate-advanced.md.
| Interface | Properties | Purpose |
|---|---|---|
IAuditable | DateTime CreatedAt, Option<DateTime> UpdatedAt | Creation/modification time tracking |
IAuditableWithUser | + Option<string> CreatedBy/UpdatedBy | + User tracking |
ISoftDeletable | Option<DateTime> DeletedAt, bool IsDeleted | Soft delete |
ISoftDeletableWithUser | + Option<string> DeletedBy | + Deleter tracking |
IConcurrencyAware | byte[] RowVersion | Optimistic concurrency control |
Now that we understand the class hierarchy, let us look at the ID system that uniquely identifies Entities.
Entity ID System
Section titled “Entity ID System”Functorium provides a type-safe Entity ID system. It is Ulid-based, enabling time-order sorting, and is automatically generated via source generators.
IEntityId<T> Interface
Section titled “IEntityId<T> Interface”Location: Functorium.Domains.Entities.IEntityId<T>
public interface IEntityId<T> : IEquatable<T>, IComparable<T>, IParsable<T> where T : struct, IEntityId<T>{ // Ulid value Ulid Value { get; }
// Create new EntityId static abstract T New();
// Create EntityId from Ulid static abstract T Create(Ulid id);
// Create EntityId from string static abstract T Create(string id);}Why Ulid?
The following comparison shows why Functorium chose Ulid over GUID.
| Characteristics | GUID | Ulid |
|---|---|---|
| Size | 128bit | 128bit |
| Sorting | Random | Time-ordered |
| Readability | 36 chars (with hyphens) | 26 chars |
| Index performance | Low (random) | High (sequential) |
The key difference is sorting and index performance. Ulid is sorted in time order, resulting in good database index performance and the ability to extract creation time.
EntityIdGenerator (Source Generator)
Section titled “EntityIdGenerator (Source Generator)”When the [GenerateEntityId] attribute is applied to an Entity class, the ID type for that Entity is automatically generated.
Location: Functorium.Domains.Entities.GenerateEntityIdAttribute
using Functorium.Domains.Entities;
[GenerateEntityId] // Auto-generates ProductId, ProductIdComparer, ProductIdConverterpublic class Product : Entity<ProductId>{ // ...}Generated Code
Section titled “Generated Code”[GenerateEntityId] automatically generates the following types. It includes not only the ID itself but also auxiliary types needed for EF Core integration and serialization.
| Generated Type | Purpose |
|---|---|
{Entity}Id struct | Entity identifier (Ulid-based) |
{Entity}IdComparer | EF Core ValueComparer |
{Entity}IdConverter | EF Core ValueConverter (string ↔ EntityId) |
{Entity}IdJsonConverter | System.Text.Json serialization (built-in) |
{Entity}IdTypeConverter | TypeConverter support (built-in) |
Generated EntityId Structure:
[DebuggerDisplay("{Value}")][JsonConverter(typeof(ProductIdJsonConverter))][TypeConverter(typeof(ProductIdTypeConverter))]public readonly partial record struct ProductId : IEntityId<ProductId>, IParsable<ProductId>{ // Type name constant public const string Name = "ProductId";
// Empty value constant public static readonly ProductId Empty = new(Ulid.Empty);
// Ulid value public Ulid Value { get; init; }
// Factory methods public static ProductId New(); // New ID generation public static ProductId Create(Ulid id); // Create from Ulid public static ProductId Create(string id); // Create from string
// Comparison operators public int CompareTo(ProductId other); public static bool operator <(ProductId left, ProductId right); public static bool operator >(ProductId left, ProductId right); public static bool operator <=(ProductId left, ProductId right); public static bool operator >=(ProductId left, ProductId right);
// IParsable implementation public static ProductId Parse(string s, IFormatProvider? provider); public static bool TryParse(string? s, IFormatProvider? provider, out ProductId result);
// Built-in JsonConverter, TypeConverter // ...}While the ID system provides the means to identify Entities, creation patterns define how to safely create them.
Creation Patterns
Section titled “Creation Patterns”The core of Entity implementation is separation of validation responsibilities. Value Objects and Entities have different validation responsibilities.
- Value Object: Receives primitive values and validates its own validity
- Entity: Receives already-validated Value Objects and composes them. Defines Validate only when there are Entity-level business rules
Role Differences Between Value Object and Entity
Section titled “Role Differences Between Value Object and Entity”| Category | Value Object | Entity |
|---|---|---|
| Validate | Primitive value -> returns validated value | Entity-level business rules only |
| Create | Receives primitive values | Receives Value Objects directly |
| Validation responsibility | Validates own values | Validates relationships/rules between VOs |
Note: For Value Object validation patterns, see the Value Object Implementation Guide - Implementation Patterns.
Create / CreateFromValidated Pattern
Section titled “Create / CreateFromValidated Pattern”Entities provide two creation paths. Check the purpose and behavioral differences of each path.
| Method | Purpose | Validation | ID Generation |
|---|---|---|---|
Create() | New Entity creation | VOs already validated | Newly generated |
CreateFromValidated() | ORM/Repository restoration | None | Uses existing ID |
Create Method:
Used when creating a new Entity. Receives already validated Value Objects directly.
// Create: Receives already validated Value Objects directlypublic static Product Create(ProductName name, ProductDescription description, Money price){ var id = ProductId.New(); // New ID generation var product = new Product(id, name, description, price); product.AddDomainEvent(new CreatedEvent(product.Id, name, price)); return product;}CreateFromValidated Method:
Used when restoring an Entity from ORM or Repository. Values read from the database are already validated, so they are not validated again.
public static Product CreateFromValidated( ProductId id, ProductName name, ProductDescription description, Money price, DateTime createdAt, Option<DateTime> updatedAt){ return new Product(id, name, description, price) { CreatedAt = createdAt, UpdatedAt = updatedAt };}Why are two methods needed?
- Performance: Improves performance by skipping validation when loading large numbers of Entities from the database.
- Semantics: Creating a new Entity and restoring an existing Entity have different meanings.
- ID management: Create generates a new ID, while CreateFromValidated uses an existing ID.
Pattern 1: Static Create() Factory Method
Section titled “Pattern 1: Static Create() Factory Method”Aggregate Root is created via a Create static factory method. The constructor is encapsulated as private. It receives already-validated Value Objects, creates a new Aggregate, auto-generates an ID, and publishes domain events.
// Customer Aggregate: Simple creationpublic static Customer Create( CustomerName name, Email email, Money creditLimit){ var customer = new Customer(CustomerId.New(), name, email, creditLimit); customer.AddDomainEvent(new CreatedEvent(customer.Id, name, email)); return customer;}// Product Aggregate: Creation + initial state setuppublic static Product Create( ProductName name, ProductDescription description, Money price){ var product = new Product(ProductId.New(), name, description, price); product.AddDomainEvent(new CreatedEvent(product.Id, name, price)); return product;}Create() Comparison Across All Aggregate Roots:
| Aggregate | Parameters | ID Generation | Event |
|---|---|---|---|
Product.Create() | ProductName, ProductDescription, Money | ProductId.New() | CreatedEvent |
Inventory.Create() | ProductId, Quantity | InventoryId.New() | CreatedEvent |
Order.Create() | ProductId, Quantity, Money, ShippingAddress | OrderId.New() | CreatedEvent |
Customer.Create() | CustomerName, Email, Money | CustomerId.New() | CreatedEvent |
Common Rules:
privateconstructor +public static Create()combination- Parameters are already-validated Value Objects (not primitives)
- ID is auto-generated internally via
XxxId.New() - Domain events are published at creation time
Pattern 2: CreateFromValidated() ORM Restoration
Section titled “Pattern 2: CreateFromValidated() ORM Restoration”Restores the Aggregate from data read from the DB. Validation is skipped since the data has already passed validation once.
public static Product CreateFromValidated( ProductId id, ProductName name, ProductDescription description, Money price, DateTime createdAt, Option<DateTime> updatedAt){ return new Product(id, name, description, price) { CreatedAt = createdAt, UpdatedAt = updatedAt };}Create vs CreateFromValidated Comparison:
| Item | Create() | CreateFromValidated() |
|---|---|---|
| Purpose | New Aggregate creation | ORM/Repository restoration |
| ID generation | XxxId.New() auto-issued | Passed from outside |
| Validation | VOs are already validated | Validation skipped (trusts DB data) |
| Event publishing | Calls AddDomainEvent() | No events |
| Audit fields | Auto-set (DateTime.UtcNow) | Passed from outside |
When Entity.Validate Is Needed vs Not Needed
Section titled “When Entity.Validate Is Needed vs Not Needed”Not needed — Simple VO composition:
// Value Objects are already validated -> Entity.Validate not neededpublic static Order Create(Money amount, CustomerId customerId){ var id = OrderId.New(); return new Order(id, amount, customerId);}Needed — Entity-level business rules (relationships between VOs):
The key point in the following example is the flow where Validate returns Validation<Error, Unit> and Create calls it then converts with ToFin().
// Selling price > cost rule is Entity-level validation[GenerateEntityId]public class Product : Entity<ProductId>{ #region Error Types
public sealed record SellingPriceBelowCost : DomainErrorType.Custom;
#endregion
public ProductName Name { get; private set; } public Price SellingPrice { get; private set; } public Money Cost { get; private set; }
// Validate: Entity-level business rule (selling price > cost) public static Validation<Error, Unit> Validate(Price sellingPrice, Money cost) => sellingPrice.Value > cost.Amount ? Success<Error, Unit>(unit) : DomainError.For<Product>( new SellingPriceBelowCost(), sellingPrice.Value, $"Selling price must be greater than cost. Price: {sellingPrice.Value}, Cost: {cost.Amount}");
// Create: Create Entity after calling Validate public static Fin<Product> Create(ProductName name, Price sellingPrice, Money cost) => Validate(sellingPrice, cost) .Map(_ => new Product(ProductId.New(), name, sellingPrice, cost)) .ToFin();}// Start date < end date rule is Entity-level[GenerateEntityId]public class Subscription : Entity<SubscriptionId>{ #region Error Types
public sealed record StartAfterEnd : DomainErrorType.Custom;
#endregion
public Date StartDate { get; private set; } public Date EndDate { get; private set; } public CustomerId CustomerId { get; private set; }
// Validate: Entity-level business rule (start date < end date) public static Validation<Error, Unit> Validate(Date startDate, Date endDate) => startDate < endDate ? Success<Error, Unit>(unit) : DomainError.For<Subscription>( new StartAfterEnd(), startDate.Value, $"Start date must be before end date. Start: {startDate.Value}, End: {endDate.Value}");
// Create: Create Entity after calling Validate public static Fin<Subscription> Create(Date startDate, Date endDate, CustomerId customerId) => Validate(startDate, endDate) .Map(_ => new Subscription(SubscriptionId.New(), startDate, endDate, customerId)) .ToFin();}Factory Pattern Design Guidelines
Section titled “Factory Pattern Design Guidelines”| Scenario | Recommended Approach | Example |
|---|---|---|
| Simple creation (only VOs needed) | Call static Create() directly | Customer.Create(name, email, creditLimit) |
| Parallel VO validation needed | Apply pattern (inside Usecase) | CreateProductCommand.CreateProduct() |
| External data needed | Orchestrate via Port in Usecase then call Create() | CreateOrderCommand + IProductCatalog |
| Restore from DB | CreateFromValidated() (validation skipped) | Repository Mapper |
Apply Pattern: In the Usecase, validate VOs in parallel using
(v1, v2, v3).Apply(...)tuples then callCreate(). For details, see Usecase Implementation Guide — Value Object Validation and Apply Merge Pattern.Cross-Aggregate Orchestration: When data from another Aggregate is needed, query via Port in the Usecase’s LINQ chain then call
Create(). For details, see Usecase Implementation Guide.
DDD Principle Compliance:
- Encapsulation: Block direct instantiation with
privateconstructor, expose only factory methods - Invariant protection:
Create()accepts only validated VOs, direct primitive passing not possible - Reconstruction separation: Clear distinction between
Create()(new creation) vsCreateFromValidated()(restoration) - Event consistency: Domain event publishing only on new creation, no events on restoration
- Layer responsibility: Aggregate handles only its own creation, external orchestration is Usecase’s responsibility
Having covered how to create Entities, let us now examine command methods that safely change the state of created Entities.
Command Methods and Invariant Protection
Section titled “Command Methods and Invariant Protection”Command Methods That Protect Invariants
Section titled “Command Methods That Protect Invariants”State changes are only possible through Aggregate Root methods. When business rules are violated, failure is returned as Fin<Unit>.
The key point in the following code is the pattern of returning DomainError on invariant violation failure and performing state change and event publishing on success.
// Inventory: Stock deduction (invariant: stock >= 0)// Error type definition: public sealed record InsufficientStock : DomainErrorType.Custom;public Fin<Unit> DeductStock(Quantity quantity){ if (quantity > StockQuantity) return DomainError.For<Inventory, int>( new InsufficientStock(), currentValue: StockQuantity, message: $"Insufficient stock. Current: {StockQuantity}, Requested: {quantity}");
StockQuantity = StockQuantity.Subtract(quantity); UpdatedAt = DateTime.UtcNow; AddDomainEvent(new StockDeductedEvent(Id, ProductId, quantity)); return unit;}// Product: Info update (command that always succeeds)public Product Update( ProductName name, ProductDescription description, Money price){ var oldPrice = Price;
Name = name; Description = description; Price = price; UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new UpdatedEvent(Id, name, oldPrice, price));
return this;}Child Entity Management (Add/Remove)
Section titled “Child Entity Management (Add/Remove)”Child Entity collections are encapsulated with the private List<T> + public IReadOnlyList<T> pattern.
public sealed class Product : AggregateRoot<ProductId>{ // private mutable collection private readonly List<Tag> _tags = [];
// public read-only view public IReadOnlyList<Tag> Tags => _tags.AsReadOnly();
// Add child Entity only through Root public Product AddTag(Tag tag) { if (_tags.Any(t => t.Id == tag.Id)) return this;
_tags.Add(tag); AddDomainEvent(new TagAssignedEvent(tag.Id, tag.Name)); return this; }
// Remove child Entity only through Root public Product RemoveTag(TagId tagId) { var tag = _tags.FirstOrDefault(t => t.Id == tagId); if (tag is null) return this;
_tags.Remove(tag); AddDomainEvent(new TagRemovedEvent(tagId)); return this; }}Query Methods (State Inspection)
Section titled “Query Methods (State Inspection)”Methods that inspect the state of an Entity. They have no side effects and do not change state.
// Check if product is expiredpublic bool IsExpired() => ExpirationDate < DateTime.UtcNow;
// Check if order is in a shippable statepublic bool IsShippable() => Status == OrderStatus.Confirmed;Return Types by Method Type
Section titled “Return Types by Method Type”Choose the appropriate return type based on the nature of the method.
| Method Type | Return Type | Description |
|---|---|---|
| Query (simple check) | bool, int, etc. | Side-effect-free state check |
| Query (VO calculation) | Money, Quantity, etc. | Returns calculated value object |
| Command (always succeeds) | void or this | State change without validation |
| Command (can fail) | Fin<Unit> | Possible business rule violation |
| Command (returns result) | Fin<T> | Can fail + returns calculated result |
An Aggregate Root’s command methods only change its own state. So how are child Entities inside the Aggregate managed?
Child Entity Implementation Patterns
Section titled “Child Entity Implementation Patterns”Access Only Through the Aggregate Root
Section titled “Access Only Through the Aggregate Root”Child Entities do not have independent Repositories and must be created/modified/deleted through the Aggregate Root.
// Tag: Child Entity (SharedModels)[GenerateEntityId]public sealed class Tag : Entity<TagId>{ public TagName Name { get; private set; }
#pragma warning disable CS8618 private Tag() { }#pragma warning restore CS8618
private Tag(TagId id, TagName name) : base(id) { Name = name; }
public static Tag Create(TagName name) => new(TagId.New(), name);
public static Tag CreateFromValidated(TagId id, TagName name) => new(id, name);}Child Entity Requiring Validation (OrderLine Example)
Section titled “Child Entity Requiring Validation (OrderLine Example)”When a child Entity has domain invariants, Create() returns Fin<T>:
// OrderLine: Child Entity of Order Aggregate[GenerateEntityId]public sealed class OrderLine : Entity<OrderLineId>{ public sealed record InvalidQuantity : DomainErrorType.Custom;
public ProductId ProductId { get; private set; } public Quantity Quantity { get; private set; } public Money UnitPrice { get; private set; } public Money LineTotal { get; private set; }
private OrderLine(OrderLineId id, ProductId productId, Quantity quantity, Money unitPrice, Money lineTotal) : base(id) { ProductId = productId; Quantity = quantity; UnitPrice = unitPrice; LineTotal = lineTotal; }
// Create: Validate invariant quantity > 0, auto-calculate LineTotal public static Fin<OrderLine> Create(ProductId productId, Quantity quantity, Money unitPrice) { if ((int)quantity <= 0) return DomainError.For<OrderLine, int>( new InvalidQuantity(), currentValue: quantity, message: "Order line quantity must be greater than 0");
var lineTotal = unitPrice.Multiply(quantity); return new OrderLine(OrderLineId.New(), productId, quantity, unitPrice, lineTotal); }
// CreateFromValidated: For ORM/Repository restoration (no validation) public static OrderLine CreateFromValidated( OrderLineId id, ProductId productId, Quantity quantity, Money unitPrice, Money lineTotal) => new(id, productId, quantity, unitPrice, lineTotal);}Note: For production code, see
Tests.Hosts/01-SingleHost/Src/LayeredArch.Domain/AggregateRoots/Orders/OrderLine.cs.
Having Its Own Identifier
Section titled “Having Its Own Identifier”Unlike Value Objects, child Entities have unique identifiers. This allows identifying specific elements within a collection.
// Find and remove a specific Tag by TagId from the Aggregate Rootpublic Product RemoveTag(TagId tagId){ var tag = _tags.FirstOrDefault(t => t.Id == tagId); if (tag is null) return this;
_tags.Remove(tag); AddDomainEvent(new TagRemovedEvent(tagId)); return this;}Event Publishing from Child Entities
Section titled “Event Publishing from Child Entities”Child Entities do not directly publish domain events. Instead, the Aggregate Root publishes events for child Entity changes.
// Aggregate Root (Product) publishes Tag-related eventspublic Product AddTag(Tag tag){ _tags.Add(tag); AddDomainEvent(new TagAssignedEvent(tag.Id, tag.Name)); // Root publishes return this;}
// Child Entity (Tag) directly publishing events// Tag inherits Entity<TId> so it cannot use AddDomainEvent()Domain Events
Section titled “Domain Events”Domain events represent significant occurrences in the domain. They can only be published from AggregateRoot.
Note: For the complete design of domain events (
IDomainEvent/DomainEventdefinition, Pub/Sub, handler subscription/registration, transaction considerations), see the Domain Events Guide.
Event Definition Location
Section titled “Event Definition Location”Domain events are defined as nested classes within the corresponding Entity:
[GenerateEntityId]public class Order : AggregateRoot<OrderId>{ #region Domain Events
// Domain event (nested class) public sealed record CreatedEvent(OrderId OrderId, CustomerId CustomerId, Money TotalAmount) : DomainEvent; public sealed record ConfirmedEvent(OrderId OrderId) : DomainEvent; public sealed record CancelledEvent(OrderId OrderId, string Reason) : DomainEvent;
#endregion
// Entity implementation...}Advantages:
- Event ownership is explicit in the type system (
Order.CreatedEvent) - IntelliSense shows all related events when typing
Order. - Eliminates Entity name duplication (
OrderCreatedEvent->Order.CreatedEvent) - Event publishing origin is explicit in Handler: When a Handler inherits
IDomainEventHandler<Product.CreatedEvent>, reading the code alone immediately reveals “this is an event published by the Product Entity”
Usage Examples:
// Inside Entity (concise)AddDomainEvent(new CreatedEvent(Id, customerId, totalAmount));
// From outside (explicit)public void Handle(Order.CreatedEvent @event) { ... }Event Publishing Pattern
Section titled “Event Publishing Pattern”Events are collected using AddDomainEvent() within AggregateRoot. They are published when a business-significant state change occurs.
[GenerateEntityId]public class Order : AggregateRoot<OrderId>{ #region Error Types
public sealed record InvalidStatus : DomainErrorType.Custom;
#endregion
#region Domain Events
public sealed record CreatedEvent(OrderId OrderId, Money TotalAmount) : DomainEvent; public sealed record ShippedEvent(OrderId OrderId, Address ShippingAddress) : DomainEvent;
#endregion
// Create: Publish creation event public static Order Create(Money totalAmount) { var id = OrderId.New(); var order = new Order(id, totalAmount); order.AddDomainEvent(new CreatedEvent(id, totalAmount)); return order; }
// Ship: Publish event on state change public Fin<Unit> Ship(Address address) { if (Status != OrderStatus.Confirmed) return DomainError.For<Order>( new InvalidStatus(), Status.ToString(), "Order must be confirmed before shipping");
Status = OrderStatus.Shipped; AddDomainEvent(new ShippedEvent(Id, address)); return unit; }}Checklist
Section titled “Checklist”Functorium Implementation Checklist
Section titled “Functorium Implementation Checklist”- Aggregate Root inherits
AggregateRoot<TId> - Child Entity inherits
Entity<TId> -
[GenerateEntityId]attribute applied - Child Entity collections:
private List<T>+public IReadOnlyList<T> - Return
Fin<Unit>on business rule violation - Call
AddDomainEvent()on state change - Default constructor for ORM +
#pragma warning disable CS8618 -
Create()factory method (new Entity creation) -
CreateFromValidated()method (for ORM restoration) - Define
Validate()method when there are Entity-level business rules - Domain events defined as nested records (
Order.CreatedEvent)
Troubleshooting
Section titled “Troubleshooting”EntityId type is not generated after applying [GenerateEntityId]
Section titled “EntityId type is not generated after applying [GenerateEntityId]”Cause: The Source Generator may not have run at build time, or the IDE cache may be stale.
Solution: Run a full build with dotnet build. If the IDE does not recognize it, close and reopen the solution, or run dotnet clean then build.
Warning occurs due to missing #pragma warning disable CS8618 during ORM restoration
Section titled “Warning occurs due to missing #pragma warning disable CS8618 during ORM restoration”Cause: ORMs like EF Core require a parameterless private constructor, and non-nullable properties are not initialized in this constructor, causing CS8618 warnings.
Solution: Apply #pragma warning disable CS8618 / #pragma warning restore CS8618 to the ORM default constructor. This is a conventional pattern for ORM proxy creation.
Q1. What are the criteria for choosing between Entity and AggregateRoot?
Section titled “Q1. What are the criteria for choosing between Entity and AggregateRoot?”AggregateRoot is a “transaction boundary.”
Aggregate Root:
- Is the only Entity that can be accessed directly from outside.
- Defines the consistency boundary of transactions.
- Can publish domain events.
// Order is AggregateRoot - accessed directly from outside[GenerateEntityId]public class Order : AggregateRoot<OrderId> { }
// OrderItem is Entity - accessed only through Order[GenerateEntityId]public class OrderItem : Entity<OrderItemId> { }| Question | Yes | No |
|---|---|---|
| Accessed directly from outside? | AggregateRoot | Entity |
| Publishes domain events? | AggregateRoot | Entity |
| Independently stored/queried? | AggregateRoot | Entity |
Q2. Why use Ulid?
Section titled “Q2. Why use Ulid?”Ulid provides the advantages of GUID + time ordering.
| Characteristics | GUID | Auto-increment | Ulid |
|---|---|---|---|
| Distributed generation | O | X | O |
| Time ordering | X | O | O |
| Index performance | Low | High | High |
| Predictability | Low | High | Low |
var id1 = ProductId.New(); // 01ARZ3NDEKTSV4RRFFQ69G5FAVvar id2 = ProductId.New(); // 01ARZ3NDEKTSV4RRFFQ69G5FAW
// Ulid guarantees time orderingid1 < id2 // trueQ3. When should CreateFromValidated be used?
Section titled “Q3. When should CreateFromValidated be used?”It is used when restoring an Entity from the database.
| Situation | Method to Use | Reason |
|---|---|---|
| New Entity creation | Create() | Input validation required |
| Restore from DB | CreateFromValidated() | Already validated data |
| API request processing | Create() | External input validation required |
Q4. When should domain events be published?
Section titled “Q4. When should domain events be published?”They are published when a business-significant state change occurs.
// Good: Events with business significanceAddDomainEvent(new OrderCreatedEvent(Id, CustomerId, TotalAmount));AddDomainEvent(new OrderConfirmedEvent(Id));
// Bad: Events that are too granularAddDomainEvent(new OrderStatusChangedEvent(Id, OldStatus, NewStatus)); // Too genericAddDomainEvent(new PropertyUpdatedEvent(Id, "Name", OldValue, NewValue)); // CRUD levelFor details on event handler registration, transaction considerations, etc., see the Domain Events Guide.
Q5. When is a Validate method needed in an Entity?
Section titled “Q5. When is a Validate method needed in an Entity?”It is defined only when there are Entity-level business rules (validation of relationships between VOs). See Creation Patterns — Entity.Validate.
Reference Documents
Section titled “Reference Documents”- Aggregate Design Principles (WHY) - Aggregate design principles and concepts
- Entity/Aggregate Advanced Patterns - Cross-Aggregate relationships, supplementary interfaces, practical examples
- Value Object Implementation Guide - Value Object implementation patterns, Validation and Enumeration Guide - Enumerations, Application validation, FAQ
- Domain Events Guide - Complete domain event design (IDomainEvent, Pub/Sub, handlers, transactions)
- Error System: Basics and Naming - Error handling basic principles and naming conventions
- Error System: Domain/Application Errors - Domain/Application error definition and test patterns
- Domain Modeling Overview - Domain modeling overview
- Usecase Implementation Guide - Using Aggregates in Application Layer (Apply pattern, Cross-Aggregate orchestration)
- Adapter Implementation Guide - EF Core integration, Persistence Model mapping
- Unit Testing Guide