Skip to content

Domain Implementation Results

We prove the scenarios defined in the business requirements by executing them with the type structure implemented in the code design. Normal scenarios (1-7) verify how types represent valid states, and rejection scenarios (8-12) verify how they block invalid states.

Business Rule: A customer has a name, email, and credit limit, and a CreatedEvent is published upon creation.

[Fact]
public void Create_ShouldPublishCreatedEvent()
{
// Arrange
var name = CustomerName.Create("John").ThrowIfFail();
var email = Email.Create("john@example.com").ThrowIfFail();
var creditLimit = Money.Create(5000m).ThrowIfFail();
// Act
var sut = Customer.Create(name, email, creditLimit);
// Assert
sut.Id.ShouldNotBe(default);
sut.DomainEvents.ShouldContain(e => e is Customer.CreatedEvent);
}
[Fact]
public void Create_ShouldSetProperties()
{
// Arrange
var name = CustomerName.Create("John").ThrowIfFail();
var email = Email.Create("john@example.com").ThrowIfFail();
var creditLimit = Money.Create(5000m).ThrowIfFail();
// Act
var sut = Customer.Create(name, email, creditLimit);
// Assert
((string)sut.Name).ShouldBe("John");
((string)sut.Email).ShouldBe("john@example.com");
((decimal)sut.CreditLimit).ShouldBe(5000m);
}

CustomerName, Email, and Money are each validated Value Objects. Create receives already validated VOs to construct the Aggregate and publishes a CreatedEvent. The ID is auto-generated based on Ulid.

Business Rule: A product has a name, description, and price, and a CreatedEvent is published upon creation.

[Fact]
public void Create_ShouldPublishCreatedEvent()
{
// Act
var sut = Product.Create(
ProductName.Create("Test Product").ThrowIfFail(),
ProductDescription.Create("Test Description").ThrowIfFail(),
Money.Create(100m).ThrowIfFail());
// Assert
sut.Id.ShouldNotBe(default);
sut.DomainEvents.ShouldContain(e => e is Product.CreatedEvent);
}
[Fact]
public void Create_ShouldSetProperties()
{
// Act
var sut = Product.Create(
ProductName.Create("Laptop").ThrowIfFail(),
ProductDescription.Create("Good laptop").ThrowIfFail(),
Money.Create(1500m).ThrowIfFail());
// Assert
((string)sut.Name).ShouldBe("Laptop");
((string)sut.Description).ShouldBe("Good laptop");
((decimal)sut.Price).ShouldBe(1500m);
}

Product.Create receives three VOs (ProductName, ProductDescription, Money) to construct the Aggregate. The product implements ISoftDeletableWithUser to support soft delete.

Business Rule: An order must contain at least 1 order line, and TotalAmount is automatically calculated as the sum of all lines.

[Fact]
public void Create_ShouldCalculateTotalAmount()
{
// Arrange
var line1 = OrderLine.Create(
ProductId.New(),
Quantity.Create(3).ThrowIfFail(),
Money.Create(100m).ThrowIfFail()).ThrowIfFail();
var line2 = OrderLine.Create(
ProductId.New(),
Quantity.Create(2).ThrowIfFail(),
Money.Create(50m).ThrowIfFail()).ThrowIfFail();
// Act
var sut = Order.Create(
CustomerId.New(),
[line1, line2],
ShippingAddress.Create("Seoul, Korea").ThrowIfFail()).ThrowIfFail();
// Assert
((decimal)sut.TotalAmount).ShouldBe(400m); // (3 * 100) + (2 * 50) = 400
}
[Fact]
public void Create_ShouldPublishCreatedEvent()
{
// Arrange
var customerId = CustomerId.New();
var line = OrderLine.Create(
ProductId.New(),
Quantity.Create(2).ThrowIfFail(),
Money.Create(50m).ThrowIfFail()).ThrowIfFail();
// Act
var sut = Order.Create(customerId, [line],
ShippingAddress.Create("Seoul, Korea").ThrowIfFail()).ThrowIfFail();
// Assert
sut.Id.ShouldNotBe(default);
var createdEvent = sut.DomainEvents.OfType<Order.CreatedEvent>().ShouldHaveSingleItem();
createdEvent.CustomerId.ShouldBe(customerId);
createdEvent.OrderLines.Count.ShouldBe(1);
}

Order.Create returns Fin<Order>. TotalAmount is auto-calculated as the sum of individual OrderLine.LineTotal (Quantity * UnitPrice), and the status is immediately set to Pending upon creation. CustomerId is a cross-Aggregate reference that holds the Customer Aggregate’s ID as a value.

Business Rule: Order status transitions in the order Pending -> Confirmed -> Shipped -> Delivered, with domain events published for each transition.

[Fact]
public void Confirm_ShouldTransitionFromPending()
{
// Arrange
var sut = CreateSampleOrder(); // Status = Pending
// Act
var actual = sut.Confirm();
// Assert
actual.IsSucc.ShouldBeTrue();
sut.Status.ShouldBe(OrderStatus.Confirmed);
}
[Fact]
public void Ship_ShouldTransitionFromConfirmed()
{
// Arrange
var sut = CreateSampleOrder();
sut.Confirm(); // Pending -> Confirmed
// Act
var actual = sut.Ship();
// Assert
actual.IsSucc.ShouldBeTrue();
sut.Status.ShouldBe(OrderStatus.Shipped);
}
[Fact]
public void Confirm_ShouldPublishConfirmedEvent()
{
// Arrange
var sut = CreateSampleOrder();
sut.ClearDomainEvents();
// Act
sut.Confirm();
// Assert
sut.DomainEvents.OfType<Order.ConfirmedEvent>().ShouldHaveSingleItem();
}

OrderStatus defines allowed transition rules via CanTransitionTo. Confirm(), Ship(), Deliver() internally call TransitionTo, which verifies whether the transition from the current state to the target state is allowed, then changes the state and publishes an event.

[Theory]
[InlineData("Pending", "Confirmed", true)]
[InlineData("Pending", "Cancelled", true)]
[InlineData("Confirmed", "Shipped", true)]
[InlineData("Confirmed", "Cancelled", true)]
[InlineData("Shipped", "Delivered", true)]
[InlineData("Pending", "Shipped", false)]
[InlineData("Confirmed", "Pending", false)]
[InlineData("Shipped", "Cancelled", false)]
[InlineData("Delivered", "Cancelled", false)]
public void CanTransitionTo_ShouldReturnExpected(string from, string to, bool expected)
{
var fromStatus = OrderStatus.CreateFromValidated(from);
var toStatus = OrderStatus.CreateFromValidated(to);
var actual = fromStatus.CanTransitionTo(toStatus);
actual.ShouldBe(expected);
}

State transition rules are encapsulated inside the OrderStatus Value Object, structurally preventing the Aggregate from transitioning to an invalid state.

Business Rule: When a product is soft deleted, the deleter and timestamp are recorded. When restored, the deletion information is cleared. Both delete and restore are idempotent.

[Fact]
public void Delete_ShouldSetDeletedAtAndDeletedBy()
{
// Arrange
var sut = CreateSampleProduct();
// Act
sut.Delete("admin@test.com");
// Assert
sut.DeletedAt.IsSome.ShouldBeTrue();
sut.DeletedBy.ShouldBe(Some("admin@test.com"));
}
[Fact]
public void Delete_ShouldBeIdempotent_WhenAlreadyDeleted()
{
// Arrange
var sut = CreateSampleProduct();
sut.Delete("admin@test.com");
sut.ClearDomainEvents();
// Act
sut.Delete("other@test.com"); // Second delete attempt
// Assert
sut.DomainEvents.ShouldBeEmpty(); // No events added
sut.DeletedBy.ShouldBe(Some("admin@test.com")); // Original deleter preserved
}
[Fact]
public void Restore_ShouldClearDeletedAtAndDeletedBy()
{
// Arrange
var sut = CreateSampleProduct();
sut.Delete("admin@test.com");
// Act
sut.Restore();
// Assert
sut.DeletedAt.IsNone.ShouldBeTrue();
sut.DeletedBy.IsNone.ShouldBeTrue();
}
[Fact]
public void Restore_ShouldBeIdempotent_WhenNotDeleted()
{
// Arrange
var sut = CreateSampleProduct();
sut.ClearDomainEvents();
// Act
sut.Restore(); // Restore attempt on non-deleted state
// Assert
sut.DomainEvents.ShouldBeEmpty(); // No events added
}

DeletedAt and DeletedBy are expressed as Option<T>. On deletion they transition to Some(value), on restoration to None. Calling delete (restore) again on an already deleted (restored) state does not add events, guaranteeing idempotency.

Business Rule: Inventory supports deduction and addition, with domain events published for each operation.

[Fact]
public void DeductStock_ShouldSucceed_WhenSufficientStock()
{
// Arrange
var sut = CreateSampleInventory(10);
sut.ClearDomainEvents();
// Act
var result = sut.DeductStock(Quantity.Create(3).ThrowIfFail());
// Assert
result.IsSucc.ShouldBeTrue();
((int)sut.StockQuantity).ShouldBe(7);
sut.DomainEvents.ShouldContain(e => e is Inventory.StockDeductedEvent);
}
[Fact]
public void AddStock_ShouldIncreaseQuantity()
{
// Arrange
var sut = CreateSampleInventory(10);
sut.ClearDomainEvents();
// Act
sut.AddStock(Quantity.Create(5).ThrowIfFail());
// Assert
((int)sut.StockQuantity).ShouldBe(15);
sut.DomainEvents.ShouldContain(e => e is Inventory.StockAddedEvent);
}

Inventory is a dedicated inventory management Aggregate separated from Product. It supports optimistic concurrency control (IConcurrencyAware) for high-frequency changes (per-order DeductStock). DeductStock returns Fin<Unit> to explicitly express failure on insufficient stock.

Scenario 7: Credit Limit Validation (Pass)

Section titled “Scenario 7: Credit Limit Validation (Pass)”

Business Rule: If the order amount is within the customer’s credit limit, the order is allowed. The sum of existing orders is also considered.

[Fact]
public void ValidateCreditLimit_ReturnsSuccess_WhenAmountWithinLimit()
{
// Arrange
var customer = CreateSampleCustomer(creditLimit: 5000m);
var orderAmount = Money.Create(3000m).ThrowIfFail();
// Act
var actual = _sut.ValidateCreditLimit(customer, orderAmount);
// Assert
actual.IsSucc.ShouldBeTrue();
}
[Fact]
public void ValidateCreditLimit_ReturnsSuccess_WhenAmountEqualsLimit()
{
// Arrange
var customer = CreateSampleCustomer(creditLimit: 5000m);
var orderAmount = Money.Create(5000m).ThrowIfFail();
// Act
var actual = _sut.ValidateCreditLimit(customer, orderAmount);
// Assert
actual.IsSucc.ShouldBeTrue();
}
[Fact]
public void ValidateCreditLimitWithExistingOrders_ReturnsSuccess_WhenTotalWithinLimit()
{
// Arrange
var customer = CreateSampleCustomer(creditLimit: 5000m);
var existingOrders = Seq(
CreateSampleOrder(unitPrice: 1000m),
CreateSampleOrder(unitPrice: 1500m));
var newOrderAmount = Money.Create(2000m).ThrowIfFail();
// Act
var actual = _sut.ValidateCreditLimitWithExistingOrders(customer, existingOrders, newOrderAmount);
// Assert
actual.IsSucc.ShouldBeTrue();
}

OrderCreditCheckService is a cross-Aggregate validation service implementing IDomainService. It compares the Customer’s credit limit with the Order’s amount to validate business rules that cannot be performed within a single Aggregate. ValidateCreditLimitWithExistingOrders validates the cumulative amount including existing order totals.

Having verified how valid states are represented in normal scenarios, we now verify how invalid states are blocked in rejection scenarios. The true value of the domain type system lies not in ‘representing allowed states’ but in ‘making disallowed states structurally impossible.‘

Business Rule: Orders without order lines cannot be created.

[Fact]
public void Create_ShouldFail_WhenOrderLinesEmpty()
{
// Act
var actual = Order.Create(
CustomerId.New(),
[],
ShippingAddress.Create("Seoul, Korea").ThrowIfFail());
// Assert
actual.IsFail.ShouldBeTrue();
}

Order.Create returns a Fin.Fail containing an EmptyOrderLines error when the order line list is empty. The Fin<Order> return type forces the caller to handle failure, structurally preventing the creation of empty orders.

Business Rule: Disallowed state transitions are rejected. (e.g., Pending -> Shipped, Delivered -> Cancelled)

[Fact]
public void Ship_ShouldFail_WhenPending()
{
// Arrange
var sut = CreateSampleOrder(); // Status = Pending
// Act
var actual = sut.Ship(); // Attempting Pending -> Shipped
// Assert
actual.IsFail.ShouldBeTrue();
}
[Fact]
public void Deliver_ShouldFail_WhenCancelled()
{
// Arrange
var sut = CreateSampleOrder();
sut.Cancel(); // Pending -> Cancelled
// Act
var actual = sut.Deliver(); // Attempting Cancelled -> Delivered
// Assert
actual.IsFail.ShouldBeTrue();
}
[Fact]
public void Cancel_ShouldFail_WhenDelivered()
{
// Arrange
var sut = CreateSampleOrder();
sut.Confirm();
sut.Ship();
sut.Deliver(); // Shipped -> Delivered
// Act
var actual = sut.Cancel(); // Attempting Delivered -> Cancelled
// Assert
actual.IsFail.ShouldBeTrue();
}

Inside TransitionTo, when OrderStatus.CanTransitionTo detects a disallowed transition, it returns an InvalidOrderStatusTransition error. Ship is only possible after Confirmed, and Deliver is only possible after Shipped.

Business Rule: Soft-deleted products cannot be modified. After restoration, modification becomes possible.

[Fact]
public void Update_ReturnsFail_WhenProductIsDeleted()
{
// Arrange
var sut = CreateSampleProduct();
sut.Delete("admin@test.com");
var newName = ProductName.Create("New Name").ThrowIfFail();
var newDescription = ProductDescription.Create("New Desc").ThrowIfFail();
var newPrice = Money.Create(200m).ThrowIfFail();
// Act
var actual = sut.Update(newName, newDescription, newPrice);
// Assert
actual.IsFail.ShouldBeTrue();
}
[Fact]
public void Restore_ShouldAllowUpdate_AfterDeleteAndRestore()
{
// Arrange
var sut = CreateSampleProduct();
sut.Delete("admin@test.com");
sut.Restore(); // Restore
var newName = ProductName.Create("Restored Name").ThrowIfFail();
var newDescription = ProductDescription.Create("Restored Desc").ThrowIfFail();
var newPrice = Money.Create(300m).ThrowIfFail();
// Act
var actual = sut.Update(newName, newDescription, newPrice);
// Assert
actual.IsSucc.ShouldBeTrue();
((string)sut.Name).ShouldBe("Restored Name");
}

The Update method checks DeletedAt.IsSome in its first guard. In the deleted state, it returns an AlreadyDeleted error to block state changes. After Restore(), DeletedAt is reset to None, making modification possible again.

Business Rule: More than the current stock cannot be deducted.

[Fact]
public void DeductStock_ShouldFail_WhenInsufficientStock()
{
// Arrange
var sut = CreateSampleInventory(2); // Stock: 2
// Act
var result = sut.DeductStock(Quantity.Create(5).ThrowIfFail()); // Attempting to deduct 5
// Assert
result.IsFail.ShouldBeTrue();
}

DeductStock returns an InsufficientStock error when the requested quantity exceeds the current stock (StockQuantity). The Fin<Unit> return type forces the caller to handle insufficient stock situations.

Business Rule: If the order amount exceeds the customer’s credit limit, the order is rejected. The same applies when summing existing orders.

[Fact]
public void ValidateCreditLimit_ReturnsFail_WhenAmountExceedsLimit()
{
// Arrange
var customer = CreateSampleCustomer(creditLimit: 5000m);
var orderAmount = Money.Create(6000m).ThrowIfFail();
// Act
var actual = _sut.ValidateCreditLimit(customer, orderAmount);
// Assert
actual.IsFail.ShouldBeTrue();
}
[Fact]
public void ValidateCreditLimitWithExistingOrders_ReturnsFail_WhenTotalExceedsLimit()
{
// Arrange
var customer = CreateSampleCustomer(creditLimit: 5000m);
var existingOrders = Seq(
CreateSampleOrder(unitPrice: 2000m),
CreateSampleOrder(unitPrice: 2000m)); // Existing total: 4000
var newOrderAmount = Money.Create(2000m).ThrowIfFail(); // 4000 + 2000 = 6000 > 5000
// Act
var actual = _sut.ValidateCreditLimitWithExistingOrders(customer, existingOrders, newOrderAmount);
// Assert
actual.IsFail.ShouldBeTrue();
}

OrderCreditCheckService provides two validations: single order (ValidateCreditLimit) and cumulative orders (ValidateCreditLimitWithExistingOrders). In both cases, a CreditLimitExceeded error is returned when the limit is exceeded.

Domain errors are defined as nested records within each Aggregate or Domain Service. The reason for using per-type sealed records instead of a centralized ErrorCode enum is that the type system guarantees error origin and classification, and DomainError.For<T>() auto-generates error codes.

Each rejection scenario structurally identifies failure causes through sealed record error types. DomainError.For<TDomain>() auto-generates error codes in the format DomainErrors.{TypeName}.{ErrorName}, enabling pattern matching without string comparison.

Aggregate/ServiceError TypeTrigger ConditionReturn Type
OrderEmptyOrderLinesOrder lines are emptyFin<Order>
OrderInvalidOrderStatusTransitionDisallowed state transitionFin<Unit>
OrderLineInvalidQuantityOrder line quantity validation failureFin<OrderLine>
OrderStatusInvalidValueInvalid status stringFin<OrderStatus>
ProductAlreadyDeletedAttempting to modify deleted productFin<Product>
InventoryInsufficientStockDeduction during insufficient stockFin<Unit>
OrderCreditCheckServiceCreditLimitExceededOrder amount exceeds credit limitFin<Unit>
RequirementScenarioResultVerification Method
Customer creation and property setting1. Customer creationCreatedEvent published, properties setShouldContain, ShouldBe
Product creation2. Product creationCreatedEvent publishedShouldContain
Automatic order amount calculation3. Order creationTotalAmount = Sum(LineTotal)ShouldBe(400m)
Order status transition rules4. Status transition chainPending -> Confirmed -> Shipped -> DeliveredIsSucc, ShouldBe(OrderStatus.*)
Product lifecycle management + idempotency5. Soft delete/restoreDelete/restore + idempotency guaranteedIsSome/IsNone, ShouldBeEmpty
Inventory management6. Inventory deduction/additionQuantity change + event publicationShouldBe(7), ShouldContain
Cross-Aggregate validation7. Credit limit passOrder within limit allowedIsSucc
Order line invariants8. Empty order linesEmptyOrderLines errorIsFail
State transition invariants9. Invalid transitionInvalidOrderStatusTransition errorIsFail
Deleted state behavior blocking10. Modifying deleted productAlreadyDeleted errorIsFail
Inventory invariants11. Insufficient stockInsufficientStock errorIsFail
Credit limit rules12. Credit limit exceededCreditLimitExceeded errorIsFail

Having verified the behavior of the domain model through individual scenarios, we now provide a comprehensive summary of how DDD tactical patterns and functional types collaborate in this example to guarantee business rules.

Eric Evans’ DDD building blocks determine “which rules to guarantee where.”

PatternApplicationRoleGuarantee Effect
Value ObjectMoney, Quantity, OrderStatus, CustomerName, Email, ShippingAddress, etc.Guarantees validity and immutability of single valuesBlocks the very existence of invalid values
Aggregate RootCustomer, Product, Order, Inventory, TagInvariant boundary and single entry point for consistencyAll state changes occur only through the Aggregate
EntityOrderLineIdentifiable object within the AggregateLifecycle dependent on Aggregate, preventing orphan entities
Domain EventCreatedEvent, ConfirmedEvent, StockDeductedEvent, etc.Tracking and propagation of state changesAll changes explicitly recorded
Domain ServiceOrderCreditCheckServiceCross-Aggregate business rule validationRules that cannot be resolved by a single Aggregate separated into a dedicated service
Cross-Aggregate ReferenceOrder.CustomerId, Inventory.ProductIdLoose ID-based connection between AggregatesReferences maintained without violating Aggregate boundaries

While DDD tactical patterns determine ‘which rules to guarantee where,’ the functional type system provides ‘how to delegate rule verification to the compiler.‘

Functorium’s functional types provide “how to delegate rule verification to the compiler.”

TypeApplicationEffect
Fin<T>Order.Create, DeductStock, ValidateCreditLimit, etc.Expresses failure via return type instead of exceptions, forcing callers to handle failures
Option<T>DeletedAt, DeletedBy, UpdatedAtType-safe optional value expression instead of null, clearly distinguishing presence/absence of deleted state via IsSome/IsNone
DomainErrorType.CustomEmptyOrderLines, InsufficientStock, CreditLimitExceeded, etc.Structurally identifies failure causes via sealed record instead of string messages, auto-generates error codes
DomainError.For<T>()All error creationAuto-generates error codes in DomainErrors.{TypeName}.{ErrorName} format, enabling precise branching via pattern matching
Value Object FactoryMoney.Create, Quantity.Create, OrderStatus.CreateValidation complete at creation time, no need to re-verify validity in subsequent domain logic
CreateFromValidatedORM restoration for all AggregatesSkips validation and event publication, trusts already persisted data to separate domain creation from ORM restoration

The structural rules of the domain model are automatically verified using Functorium’s ArchitectureRules framework. 6 test classes guarantee the structural consistency of DDD building blocks.

Test ClassVerification TargetKey Rules
ValueObjectArchitectureRuleTestsValue Object (Money, Quantity, CustomerName, Email, etc.)public sealed, immutability, Create/Validate factory
EntityArchitectureRuleTests5 AggregateRoots + OrderLine (Entity)public sealed, Create/CreateFromValidated, [GenerateEntityId], private constructor
DomainEventArchitectureRuleTests19 Domain Eventssealed record, Event suffix
DomainServiceArchitectureRuleTestsOrderCreditCheckServicepublic sealed, stateless, Fin return, no IObservablePort dependency, not a record
SpecificationArchitectureRuleTests6 Specificationspublic sealed, Specification<> inheritance, residing in domain layer