Skip to content

Domain Code Design

The rules defined in natural language in the business requirements were classified as invariants and type strategies were derived in the type design decisions. This document maps those strategies to C# and Functorium DDD building blocks, and examines the concrete code implementation of each pattern.

Design Decision to C# Implementation Mapping

Section titled “Design Decision to C# Implementation Mapping”

The following table shows the 1:1 mapping between design decisions and implementation patterns. Each pattern is examined in code in the subsequent sections.

Design DecisionFunctorium TypeExampleGuarantee Effect
Single value validation + immutability + normalizationSimpleValueObject<T> + Validate chainCustomerName, Email, ProductName, TagNameValidation at creation, Trim/ToLower normalization, empty string blocking
Comparable single value + arithmetic operationsComparableSimpleValueObject<T>Money, QuantitySize comparison (>, <), arithmetic (Add, Subtract), summation (Sum)
Smart Enum + state transition rulesSimpleValueObject<string> + HashMap transition mapOrderStatusOnly allowed transitions possible, error returned on invalid transition
Aggregate Root dual factoryAggregateRoot<TId> + Create/CreateFromValidatedCustomer, Product, Order, Inventory, TagSeparation of domain creation (validation+events) and ORM restoration (no validation)
Child entity + collection managementEntity<TId> + private List + IReadOnlyList exposureOrderLine (child of Order)External direct collection modification prevented
Cross-Aggregate business rulesIDomainServiceOrderCreditCheckServiceCredit limit validation between Customer and Order
Queryable domain specificationsExpressionSpecification<T>ProductNameUniqueSpec, CustomerEmailSpecExpression Tree based EF Core SQL auto-translation
Persistence abstractionIRepository<T, TId> + custom methodsICustomerRepositorySpecification-based existence check (Exists)
Domain events + domain errorsNested sealed record : DomainEvent / DomainErrorType.CustomCustomer.CreatedEvent, Order.EmptyOrderLinesEvents/errors cohesive within Aggregate at namespace level
Soft Delete + guardISoftDeletableWithUser + DeletedAt.IsSome guardProduct.Update()Blocks changes to deleted Aggregates

Each value object inherits from SimpleValueObject<T> and chains validation rules in the Validate method. The private constructor blocks new, exposing only the Create factory.

CustomerName — String length validation + Trim normalization:

public sealed class CustomerName : SimpleValueObject<string>
{
public const int MaxLength = 100;
private CustomerName(string value) : base(value) { }
public static Fin<CustomerName> Create(string? value) =>
CreateFromValidation(Validate(value), v => new CustomerName(v));
public static Validation<Error, string> Validate(string? value) =>
ValidationRules<CustomerName>
.NotNull(value)
.ThenNotEmpty()
.ThenNormalize(v => v.Trim())
.ThenMaxLength(MaxLength);
public static CustomerName CreateFromValidated(string value) => new(value);
public static implicit operator string(CustomerName name) => name.Value;
}

Email — Regex matching + ToLowerInvariant normalization:

public sealed partial class Email : SimpleValueObject<string>
{
public const int MaxLength = 320;
private Email(string value) : base(value) { }
public static Fin<Email> Create(string? value) =>
CreateFromValidation(Validate(value), v => new Email(v));
public static Validation<Error, string> Validate(string? value) =>
ValidationRules<Email>
.NotNull(value)
.ThenNotEmpty()
.ThenNormalize(v => v.Trim().ToLowerInvariant())
.ThenMaxLength(MaxLength)
.ThenMatches(EmailRegex(), "Invalid email format");
public static Email CreateFromValidated(string value) => new(value);
public static implicit operator string(Email email) => email.Value;
[GeneratedRegex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.Compiled)]
private static partial Regex EmailRegex();
}

All single-value VOs follow the same 4 components:

ComponentRole
Create(string?) -> Fin<T>Validates external input to create VO, returns Fin.Fail on failure
Validate(string?) -> Validation<Error, string>Used for applicative composition in the Application Layer
CreateFromValidated(string)For ORM restoration, directly creates from already validated value
implicit operatorSupports VO -> primitive type conversion

The core of these 4 components is separation of creation paths. External input must always go through Create for validation, while ORM restoration uses CreateFromValidated to skip validation. Both paths are protected by a private constructor, making it impossible to create instances that bypass validation.

2. ComparableSimpleValueObject + Arithmetic Operations

Section titled “2. ComparableSimpleValueObject + Arithmetic Operations”

ComparableSimpleValueObject<T> supports size comparison operations (>, <, >=, <=) and encapsulates domain arithmetic operations.

Money — Allows only positive values, provides identity element for summation (Zero):

public sealed class Money : ComparableSimpleValueObject<decimal>
{
/// <summary>
/// Identity element for addition
/// </summary>
public static readonly Money Zero = new(0m);
private Money(decimal value) : base(value) { }
public static Fin<Money> Create(decimal value) =>
CreateFromValidation(Validate(value), v => new Money(v));
public static Validation<Error, decimal> Validate(decimal value) =>
ValidationRules<Money>
.Positive(value);
public static Money CreateFromValidated(decimal value) => new(value);
public static implicit operator decimal(Money money) => money.Value;
public Money Add(Money other) => new(Value + other.Value);
public Fin<Money> Subtract(Money other) => Create(Value - other.Value);
public Money Multiply(decimal factor) => new(Value * factor);
public static Money Sum(IEnumerable<Money> values) =>
values.Aggregate(Zero, (acc, m) => acc.Add(m));
}

Add always succeeds (returns Money), but Subtract may produce a negative result so it returns Fin<Money>. Sum supports LINQ summation with the Zero identity element and Aggregate.

Quantity — 0 or more, clamp-style subtraction:

public sealed class Quantity : ComparableSimpleValueObject<int>
{
private Quantity(int value) : base(value) { }
public static Fin<Quantity> Create(int value) =>
CreateFromValidation(Validate(value), v => new Quantity(v));
public static Validation<Error, int> Validate(int value) =>
ValidationRules<Quantity>
.NonNegative(value);
public static Quantity CreateFromValidated(int value) => new(value);
public static implicit operator int(Quantity quantity) => quantity.Value;
public Quantity Add(int amount) => new(Value + amount);
public Quantity Subtract(int amount) => new(Math.Max(0, Value - amount));
}

Subtract clamps the result to 0 or more with Math.Max(0, ...). Unlike Money, it applies a floor limit instead of an error because when inventory deduction goes negative, the Aggregate (Inventory) handles it as an InsufficientStock error.

The design difference between Money and Quantity reflects the difference in domain semantics. Money’s Subtract returns Fin<Money> because the result of monetary subtraction can be negative, which is an invalid state in the domain. In contrast, Quantity’s Subtract clamps to 0, and the actual insufficient stock validation is performed at the Aggregate level.

Having guaranteed single-value validity and arithmetic operations, the next step is to express enumeration values and state transition rules as types.

3. Smart Enum — OrderStatus + Transition Rules

Section titled “3. Smart Enum — OrderStatus + Transition Rules”

OrderStatus is a Smart Enum pattern using SimpleValueObject<string>. Allowed transition rules are declared with HashMap, and state transitions are controlled via CanTransitionTo/TransitionTo.

public sealed class OrderStatus : SimpleValueObject<string>
{
#region Error Types
public sealed record InvalidValue : DomainErrorType.Custom;
#endregion
public static readonly OrderStatus Pending = new("Pending");
public static readonly OrderStatus Confirmed = new("Confirmed");
public static readonly OrderStatus Shipped = new("Shipped");
public static readonly OrderStatus Delivered = new("Delivered");
public static readonly OrderStatus Cancelled = new("Cancelled");
private static readonly HashMap<string, OrderStatus> All = HashMap(
("Pending", Pending),
("Confirmed", Confirmed),
("Shipped", Shipped),
("Delivered", Delivered),
("Cancelled", Cancelled));
private static readonly HashMap<string, Seq<string>> AllowedTransitions = HashMap(
("Pending", Seq("Confirmed", "Cancelled")),
("Confirmed", Seq("Shipped", "Cancelled")),
("Shipped", Seq("Delivered")));
private OrderStatus(string value) : base(value) { }
public static Fin<OrderStatus> Create(string value) =>
Validate(value).ToFin();
public static Validation<Error, OrderStatus> Validate(string value) =>
All.Find(value)
.ToValidation(DomainError.For<OrderStatus>(
new InvalidValue(),
currentValue: value,
message: $"Invalid order status: '{value}'"));
public static OrderStatus CreateFromValidated(string value) =>
All.Find(value)
.IfNone(() => throw new InvalidOperationException(
$"Invalid order status for CreateFromValidated: '{value}'"));
public bool CanTransitionTo(OrderStatus target) =>
AllowedTransitions.Find(Value)
.Map(allowed => allowed.Any(v => v == target.Value))
.IfNone(false);
public static implicit operator string(OrderStatus status) => status.Value;
}

Transition rule summary:

Current StateAllowed Transition Targets
PendingConfirmed, Cancelled
ConfirmedShipped, Cancelled
ShippedDelivered
Delivered(terminal state)
Cancelled(terminal state)

States not in AllowedTransitions (Delivered, Cancelled) cause CanTransitionTo to always return false, functioning as terminal states.

Having defined the creation and validation patterns for value objects, we now look at the creation strategy for Aggregate Roots that compose these value objects.

All Aggregate Roots use the dual factory pattern:

FactoryPurposeValidationEvents
Create(VO...)Domain creationReceives already validated VOsPublishes domain events
CreateFromValidated(id, VO..., audit...)ORM/Repository restorationNone (trusts DB data)None

Customer.Create() — Receives VOs and creates + publishes event:

public 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;
}

Customer.CreateFromValidated() — For ORM restoration:

public static Customer CreateFromValidated(
CustomerId id,
CustomerName name,
Email email,
Money creditLimit,
DateTime createdAt,
Option<DateTime> updatedAt)
{
return new Customer(id, name, email, creditLimit)
{
CreatedAt = createdAt,
UpdatedAt = updatedAt
};
}

Product.Update() — Soft Delete guard:

A guard pattern that blocks changes to deleted Aggregates. Returns an AlreadyDeleted error if DeletedAt.IsSome.

public Fin<Product> Update(
ProductName name,
ProductDescription description,
Money price)
{
if (DeletedAt.IsSome)
return DomainError.For<Product>(
new AlreadyDeleted(),
Id.ToString(),
"Cannot update a deleted product");
var oldPrice = Price;
Name = name;
Description = description;
Price = price;
UpdatedAt = DateTime.UtcNow;
AddDomainEvent(new UpdatedEvent(Id, name, oldPrice, price));
return this;
}

Product.Delete() — Idempotent deletion:

Calling Delete() again on an already deleted state returns this without error.

public Product Delete(string deletedBy)
{
if (DeletedAt.IsSome)
return this;
DeletedAt = DateTime.UtcNow;
DeletedBy = deletedBy;
AddDomainEvent(new DeletedEvent(Id, deletedBy));
return this;
}

OrderLine is a child entity inheriting from Entity<OrderLineId>, existing only within the Order Aggregate boundary.

OrderLine.Create() — Quantity > 0 validation + LineTotal auto-calculation:

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);
}

The Quantity VO allows 0 or more, but in the order line context, 0 quantity is meaningless, so additional validation is performed at the Entity level. LineTotal is auto-computed via Money.Multiply to ensure consistency.

Order.Create() — Empty order line blocking + TotalAmount auto-calculation:

public static Fin<Order> Create(
CustomerId customerId,
IEnumerable<OrderLine> orderLines,
ShippingAddress shippingAddress)
{
var lines = orderLines.ToList();
if (lines.Count == 0)
return DomainError.For<Order, int>(
new EmptyOrderLines(),
currentValue: 0,
message: "Order must contain at least one order line");
var totalAmount = Money.CreateFromValidated(lines.Sum(l => (decimal)l.LineTotal));
var order = new Order(OrderId.New(), customerId, lines, totalAmount, shippingAddress);
var lineInfos = Seq(lines.Select(l => new OrderLineInfo(l.ProductId, l.Quantity, l.UnitPrice, l.LineTotal)));
order.AddDomainEvent(new CreatedEvent(order.Id, customerId, lineInfos, totalAmount));
return order;
}

Order manages child entities with a private List<OrderLine> and exposes only IReadOnlyList externally:

private readonly List<OrderLine> _orderLines = [];
public IReadOnlyList<OrderLine> OrderLines => _orderLines.AsReadOnly();

Having defined the ownership relationships within the Aggregate, we now look at query patterns for cross-Aggregate validation.

ExpressionSpecification<T> returns Expression<Func<T, bool>>, which EF Core automatically translates to SQL.

ProductNameUniqueSpec — Self-exclusion option:

public sealed class ProductNameUniqueSpec : ExpressionSpecification<Product>
{
public ProductName Name { get; }
public Option<ProductId> ExcludeId { get; }
public ProductNameUniqueSpec(ProductName name, Option<ProductId> excludeId = default)
{
Name = name;
ExcludeId = excludeId;
}
public override Expression<Func<Product, bool>> ToExpression()
{
string nameStr = Name;
string? excludeIdStr = ExcludeId.Match<string?>(id => id.ToString(), () => null);
return product => (string)product.Name == nameStr &&
(excludeIdStr == null || product.Id.ToString() != excludeIdStr);
}
}

Option<ProductId> ExcludeId supports duplicate checking excluding self during updates. default (None) is used during creation, and the current product ID is passed during updates.

CustomerEmailSpec — Email match search:

public sealed class CustomerEmailSpec : ExpressionSpecification<Customer>
{
public Email Email { get; }
public CustomerEmailSpec(Email email)
{
Email = email;
}
public override Expression<Func<Customer, bool>> ToExpression()
{
string emailStr = Email;
return customer => (string)customer.Email == emailStr;
}
}

The VO’s implicit operator is used to convert to string comparison within the Expression Tree. This conversion happens through Expression variable capture (string emailStr = Email), which EF Core can translate into a SQL WHERE clause.

OrderCreditCheckService implements cross-Aggregate business rules between Customer and Order. As a stateless service, the Application Layer queries the necessary data and passes only the minimum data.

public sealed class OrderCreditCheckService : IDomainService
{
#region Error Types
public sealed record CreditLimitExceeded : DomainErrorType.Custom;
#endregion
/// <summary>
/// Validates that the order amount is within the customer's credit limit.
/// </summary>
public Fin<Unit> ValidateCreditLimit(Customer customer, Money orderAmount)
{
if (orderAmount > customer.CreditLimit)
return DomainError.For<OrderCreditCheckService>(
new CreditLimitExceeded(),
customer.Id.ToString(),
$"Order amount {(decimal)orderAmount} exceeds customer credit limit {(decimal)customer.CreditLimit}");
return unit;
}
/// <summary>
/// Validates that the sum of existing orders and the new order is within the credit limit.
/// </summary>
public Fin<Unit> ValidateCreditLimitWithExistingOrders(
Customer customer,
Seq<Order> existingOrders,
Money newOrderAmount)
{
var totalExisting = Money.Sum(existingOrders.Map(o => o.TotalAmount));
var totalWithNew = totalExisting.Add(newOrderAmount);
if (totalWithNew > customer.CreditLimit)
return DomainError.For<OrderCreditCheckService>(
new CreditLimitExceeded(),
customer.Id.ToString(),
$"Total order amount {(decimal)totalWithNew} (existing: {(decimal)totalExisting} + new: {(decimal)newOrderAmount}) exceeds customer credit limit {(decimal)customer.CreditLimit}");
return unit;
}
}

ValidateCreditLimitWithExistingOrders calculates the existing order total with Money.Sum and adds the new order with Money.Add. The ComparableSimpleValueObject comparison operator (>) is used to determine limit exceedance. The error type CreditLimitExceeded is nested within the service to clearly identify the error source.

The core of Domain Service is that it is stateless pure logic. The Application Layer queries all necessary data and passes it, and the Domain Service executes only business rules. Thanks to this separation, Domain Service can be unit tested purely without mocks.

IRepository<T, TId> adds custom methods on top of basic CRUD to support Specification-based queries.

public interface ICustomerRepository : IRepository<Customer, CustomerId>
{
/// <summary>
/// Specification-based existence check.
/// </summary>
FinT<IO, bool> Exists(Specification<Customer> spec);
}

FinT<IO, bool> expresses IO effect and failure possibility in the type. When the Application Layer passes CustomerEmailSpec, the Repository implementation converts it to SQL via EF Core.

9. DomainEvent + DomainError Nested Records

Section titled “9. DomainEvent + DomainError Nested Records”

Domain events and error types are defined as nested sealed record types within the Aggregate. Which Aggregate an event/error originates from becomes clear at the namespace level.

Customer’s domain events:

public sealed class Customer : AggregateRoot<CustomerId>, IAuditable
{
#region Domain Events
public sealed record CreatedEvent(
CustomerId CustomerId,
CustomerName Name,
Email Email) : DomainEvent;
public sealed record CreditLimitUpdatedEvent(
CustomerId CustomerId,
Money OldCreditLimit,
Money NewCreditLimit) : DomainEvent;
public sealed record EmailChangedEvent(
CustomerId CustomerId,
Email OldEmail,
Email NewEmail) : DomainEvent;
#endregion
// ...
}

Order’s domain errors:

public sealed class Order : AggregateRoot<OrderId>, IAuditable
{
#region Error Types
public sealed record EmptyOrderLines : DomainErrorType.Custom;
public sealed record InvalidOrderStatusTransition : DomainErrorType.Custom;
#endregion
// ...
}

Events include before/after values (e.g., OldPrice, NewPrice) so event consumers can understand the changes. Error types inherit from DomainErrorType.Custom, and when creating errors with DomainError.For<T>(), the Aggregate type information is automatically included.

The return type of domain methods is determined by the possibility of failure. Fin<T> is used for operations that can fail, and the self type (T) is used for operations that always succeed. The caller can determine whether error handling is needed by looking at the return type alone.

MethodReturn TypeClassificationReason
Customer.Create()CustomerAlways succeedsReceives only already validated VOs
Customer.UpdateCreditLimit()CustomerIdempotentAlways succeeds, fluent chaining
Customer.ChangeEmail()CustomerIdempotentAlways succeeds, fluent chaining
Product.Create()ProductAlways succeedsReceives only already validated VOs
Product.Update()Fin<Product>FailableAlreadyDeleted when modifying deleted product
Product.Delete()ProductIdempotentAllows re-calling on already deleted state
Product.Restore()ProductIdempotentAllows re-calling on already restored state
Product.AssignTag()ProductIdempotentAllows re-assigning already assigned tag
Product.UnassignTag()ProductIdempotentAllows re-unassigning already unassigned tag
Order.Create()Fin<Order>FailableEmptyOrderLines on empty order lines
Order.Confirm()Fin<Unit>FailableInvalidOrderStatusTransition on invalid transition
Order.Ship()Fin<Unit>FailableInvalidOrderStatusTransition on invalid transition
Order.Deliver()Fin<Unit>FailableInvalidOrderStatusTransition on invalid transition
Order.Cancel()Fin<Unit>FailableInvalidOrderStatusTransition on invalid transition
OrderLine.Create()Fin<OrderLine>FailableInvalidQuantity on quantity 0 or less
Inventory.DeductStock()Fin<Unit>FailableInsufficientStock on insufficient stock
Inventory.AddStock()InventoryAlways succeedsStock addition is always valid
Tag.Create()TagAlways succeedsReceives only already validated VOs
Tag.Rename()TagIdempotentAlways succeeds, fluent chaining

Design principle: Methods that can fail return Fin<T>, while methods that always succeed or are idempotent return the self type. The caller can determine whether error handling is needed by looking at the return type alone.

Naive FieldSingle Value VOOwning Aggregate/EntityFinal Location
string Name (customer)CustomerNameCustomerCustomer.Name
string Email (customer)EmailCustomerCustomer.Email
decimal CreditLimitMoneyCustomerCustomer.CreditLimit
string Name (product)ProductNameProductProduct.Name
string DescriptionProductDescriptionProductProduct.Description
decimal PriceMoneyProductProduct.Price
string Name (tag)TagNameTagTag.Name
List<string> TagIdsList<TagId>ProductProduct.TagIds
string StatusOrderStatus (Smart Enum)OrderOrder.Status
string ShippingAddressShippingAddress (complex VO)OrderOrder.ShippingAddress
decimal TotalAmountMoney (auto-calculated)OrderOrder.TotalAmount
string ProductId (order line)ProductId (cross-reference)OrderLineOrderLine.ProductId
int QuantityQuantityOrderLine, InventoryOrderLine.Quantity, Inventory.StockQuantity
decimal UnitPriceMoneyOrderLineOrderLine.UnitPrice
decimal LineTotalMoney (auto-calculated)OrderLineOrderLine.LineTotal
int StockQuantityQuantityInventoryInventory.StockQuantity
string CustomerId (order)CustomerId (cross-reference)OrderOrder.CustomerId
string ProductId (inventory)ProductId (cross-reference)InventoryInventory.ProductId

This table tracks how primitive types were transformed into types with domain meaning. As string differentiated into CustomerName, Email, ProductName, etc., the compiler prevents confusing different strings. decimal is wrapped in Money and int in Quantity, making it impossible for negative amounts or invalid quantities to exist.

All primitive types (string, decimal, int) have been transformed into Value Objects with domain meaning. Cross-Aggregate references are expressed as ID value objects (CustomerId, ProductId, TagId) to maintain Aggregate boundaries. Auto-calculated fields (TotalAmount, LineTotal) are computed using VO arithmetic operations at creation time to guarantee consistency.

The implementation results verify how this type structure guarantees business scenarios.