Skip to content

Domain Type Design Decisions

This document analyzes the natural language requirements defined in the business requirements from a DDD perspective. The first step is to identify independent consistency boundaries (Aggregates) from the business areas, and the second step is to classify the rules within each boundary as invariants. The goal is to design ‘types that cannot represent invalid states,’ obtaining compile-time guarantees instead of runtime validation.

Identifying Aggregates from Business Areas

Section titled “Identifying Aggregates from Business Areas”

Before encoding business rules into types, we must first identify independent consistency boundaries from the business areas. From the 6 business topics defined in the business requirements, 5 Aggregates are derived.

Business TopicAggregateRationale
Customer ManagementCustomerCustomer-specific lifecycle, independent credit limit management
Product ManagementProductIndependent changes to product information, soft delete/restore
Order ProcessingOrderOwns order lines, consistency boundary for state transition rules
Inventory ManagementInventoryDifferent change frequency from Product, concurrency control needed
Product ClassificationTagIndependent lifecycle, shared across multiple products
Cross-Domain RulesInter-Aggregate validation -> Domain Service

Product information (name, description, price) changes and inventory quantity changes differ significantly in frequency and concurrency requirements. Product information is occasionally modified by administrators, while inventory is deducted with every order. If bundled into one boundary, inventory deduction would lock product information, and product edits would lock inventory. Separating them allows independent changes, locking, and concurrency control for each.

Tags are classification labels shared by multiple products. Tag name changes should not affect products, and tag creation/deletion should be independent of product transactions. Products reference only the tag’s ID, so the tag boundary and product boundary do not interfere with each other.

AggregateSeparation ReasonKey Invariants
CustomerCustomer-specific lifecycle, independent credit limit managementEmail uniqueness, credit limit
ProductLow-frequency product information changes, soft delete/restore neededDelete guard, product name uniqueness
TagIndependent lifecycle, referenced by ID from multiple ProductsTag name validity
OrderOwns order lines, state transition rule enforcementState transitions, TotalAmount consistency, minimum 1 line
InventoryDifferent change frequency from Product, optimistic concurrency control neededInsufficient stock validation, concurrency

Maps business terms to DDD tactical patterns.

KoreanEnglishDDD PatternRole
CustomerCustomerAggregate RootSubject of orders, owns credit limit
ProductProductAggregate RootSales catalog unit, supports soft delete
TagTagAggregate RootProduct classification label, independent lifecycle
OrderOrderAggregate RootPurchase transaction unit, state transition management
Order LineOrderLineEntity (child)Individual product item within an order, dependent on Order
InventoryInventoryAggregate RootPer-product quantity tracking, concurrency control
Customer NameCustomerNameValue ObjectString of 100 characters or less
EmailEmailValue Object320 characters or less, lowercase normalized, regex validated
Product NameProductNameValue ObjectString of 100 characters or less, unique
Product DescriptionProductDescriptionValue ObjectString of 1000 characters or less
Tag NameTagNameValue ObjectString of 50 characters or less
Shipping AddressShippingAddressValue ObjectString of 500 characters or less
MoneyMoneyValue ObjectPositive decimal, supports arithmetic operations
QuantityQuantityValue ObjectNon-negative integer, supports arithmetic operations
Order StatusOrderStatusValue Object (Smart Enum)5 states, built-in transition rules
Order Credit CheckOrderCreditCheckServiceDomain ServiceCross-Aggregate credit limit validation

To guarantee business rules in software, rules must be classified as invariants and the appropriate type strategy must be chosen for each type. An invariant is “a condition that must be true at any point in the system,” and encoding it in types allows the compiler to prevent rule violations.

In this domain, 7 types of invariants were identified.

TypeScopeKey Question
Single ValueIndividual fieldIs this value always valid?
StructuralField combinationAre derived values consistent in parent-child relationships?
State TransitionChanges over timeDo only permitted state changes occur?
LifecycleAggregate lifecycleAre behaviors blocked on deleted objects?
OwnershipChild entity boundaryDo children not escape parent boundaries?
Cross-AggregateAcross multiple AggregatesWhere are rules guaranteed that cannot be verified by a single Aggregate?
ConcurrencyConcurrent accessIs data integrity guaranteed during high-frequency changes?

Constraints requiring that individual fields always hold valid values.

Business Rules:

  • “Customer name must be 100 characters or less and cannot be empty”
  • “Email must be in a valid format and 320 characters or less”
  • “Product name must be 100 characters or less and cannot be empty”
  • “Product description must be 1000 characters or less”
  • “Tag name must be 50 characters or less and cannot be empty”
  • “Shipping address must be 500 characters or less and cannot be empty”
  • “Money must be positive”
  • “Quantity must be 0 or more”

Problem with Naive Implementation: All fields are string, decimal, int, so negative amounts, empty names, and 3000-character descriptions can enter. More seriously, CustomerName and ProductName are both string, so accidentally swapping them is silently accepted by the compiler.

Design Decision: Validate at creation time and guarantee immutability afterward. Introduce constrained types that make it impossible to create invalid values. Once a value is created, it cannot be changed, so there is no need to re-verify validity in subsequent code. Types requiring arithmetic operations use ComparableSimpleValueObject<T>, while types requiring only simple wrapping use SimpleValueObject<T>.

Simple vs Comparable Decision Criteria:

  • SimpleValueObject<T>: String wrapping types. Size comparison between values has no business meaning, and only equality is needed. Example: there is no business meaning to “which name is greater.”
  • ComparableSimpleValueObject<T>: Arithmetic operations or size comparison have business meaning. Money requires Add, Subtract, Multiply and >, < comparison (credit limit check: orderAmount > customer.CreditLimit). Quantity requires Add, Subtract and insufficient stock comparison (quantity > StockQuantity).

Result:

Business RuleResult TypeBase TypeValidation RulesNormalization
Customer name 100 char limitCustomerNameSimpleValueObject<string>NotNull -> NotEmpty -> MaxLength(100)Trim
Email formatEmailSimpleValueObject<string>NotNull -> NotEmpty -> MaxLength(320) -> Matches(Regex)Trim + lowercase
Product name 100 char limitProductNameSimpleValueObject<string>NotNull -> NotEmpty -> MaxLength(100)Trim
Product description 1000 char limitProductDescriptionSimpleValueObject<string>NotNull -> MaxLength(1000)Trim
Tag name 50 char limitTagNameSimpleValueObject<string>NotNull -> NotEmpty -> MaxLength(50)Trim
Shipping address 500 char limitShippingAddressSimpleValueObject<string>NotNull -> NotEmpty -> MaxLength(500)Trim
Money must be positiveMoneyComparableSimpleValueObject<decimal>Positive
Quantity must be 0 or moreQuantityComparableSimpleValueObject<int>NonNegative

Having ensured the validity of individual fields, the next step is to verify that relationships between fields are consistent.

Constraints requiring that field combinations always represent valid states.

Business Rules:

  • “An order must contain at least 1 order line”
  • “An order line’s LineTotal = UnitPrice * Quantity, automatically calculated”
  • “An order’s TotalAmount = sum of all OrderLine LineTotals, automatically calculated”
  • “Order line quantity must be at least 1 (the Quantity VO allows 0 or more, but 0 is meaningless in the order line context)”

Problem with Naive Implementation: If TotalAmount is set externally, it can be inconsistent with the actual OrderLine sum. If LineTotal is managed separately, it can diverge from UnitPrice * Quantity. An order can be created with an empty order line list.

Design Decision: Automatically compute derived values inside the Aggregate and block external setting.

  • OrderLine.Create() automatically computes LineTotal = UnitPrice.Multiply(Quantity). There is no path to directly specify LineTotal from outside.
  • Order.Create() automatically computes TotalAmount = Sum(lines.LineTotal). If order lines are empty, it returns an EmptyOrderLines error.
  • OrderLine performs context-specific additional validation (> 0) on Quantity. By separating VO-level validation (0 or more) and entity-level validation (1 or more), the same Quantity VO can be used in both inventory (0 allowed) and order line (1 or more) contexts.

Result:

Structural RuleComputation LocationGuarantee Mechanism
LineTotal = UnitPrice * QuantityOrderLine.Create()Auto-computed in factory method, private constructor
TotalAmount = Sum(LineTotals)Order.Create()Auto-computed in factory method, private constructor
OrderLines >= 1Order.Create()Returns Fin<Order> failure on empty list
OrderLine Quantity >= 1OrderLine.Create()Returns Fin<OrderLine> failure on 0 or less

After establishing structural consistency, we must control whether state changes over time follow the rules.

Constraints requiring that changes over time follow only prescribed rules.

Business Rules:

  • “Order status transitions only in the order Pending -> Confirmed -> Shipped -> Delivered”
  • “Can only transition to Cancelled from Pending or Confirmed status”
  • “Cannot cancel from Shipped or Delivered status”
  • “Delivered and Cancelled are terminal states”

Problem with Naive Implementation: Using string Status or enum OrderStatus allows any value to be set. Using flags like bool IsConfirmed, bool IsShipped allows contradictory states like IsConfirmed = false, IsShipped = true, and the complexity of flag combinations grows exponentially when new states are added.

Design Decision: Use the Smart Enum pattern to declaratively define allowed transitions. Implement OrderStatus as SimpleValueObject<string>, exposing only static readonly instances and restricting the constructor to private. Declare allowed transition rules as HashMap<string, Seq<string>>, and validate with the CanTransitionTo() method. Order’s state transition methods (Confirm, Ship, Deliver, Cancel) internally call TransitionTo(), which returns a Fin<Unit> failure on illegal transitions.

Why Smart Enum is better than bool flags:

  • Allowed transitions are declared as data (AllowedTransitions) and can be understood at a glance.
  • When adding a new state, just add one line to HashMap.
  • Contradictory states are structurally impossible — the state is always exactly one.

State Transition Diagram:

Result:

  • OrderStatus: SimpleValueObject<string> + Smart Enum pattern
  • 5 states: Pending, Confirmed, Shipped, Delivered, Cancelled
  • CanTransitionTo(): Allowed transition validation
  • Order.TransitionTo(): Transition execution + domain event publication

Having controlled state transitions, we now verify that behaviors are correct across the entire lifecycle of the aggregate.

Constraints requiring that the creation, modification, and deletion lifecycle of an Aggregate follows the rules.

Business Rules:

  • “Products support soft delete/restore, with the deleter and timestamp recorded”
  • “Deleted products cannot be updated”
  • “Delete/restore are idempotent — deleting an already deleted product causes no error”

Problem with Naive Implementation: Managing with bool IsDeleted provides no way to prevent Update() being called on a deleted product. Deletion timestamp/deleter information must be managed in separate fields, creating possible contradictory states like IsDeleted = false with a deletion timestamp present.

Design Decision: Combine the ISoftDeletableWithUser interface with a delete guard. Product implements ISoftDeletableWithUser to manage DeletedAt and DeletedBy as Option<T>. The Update() method returns an AlreadyDeleted error if DeletedAt.IsSome. Delete() and Restore() are designed as idempotent — if already deleted/restored, they return this without any action.

Result:

  • Product: AggregateRoot<ProductId> + IAuditable + ISoftDeletableWithUser
  • DeletedAt, DeletedBy: null-safe management via Option<T>
  • Update(): Delete guard -> Fin<Product> (failable)
  • Delete(), Restore(): Idempotent -> Product (always succeeds)
  • Dual factory: Create (domain creation, event publication) + CreateFromValidated (ORM restoration, no events)

Having managed the lifecycle, we now verify that ownership relationships within the aggregate do not cross boundaries.

Constraints requiring that child entities within an Aggregate do not escape the boundary.

Business Rules:

  • “Order lines are dependent on orders — they cannot exist independently”
  • “Tags are independent Aggregates, and products reference only the tag’s ID”
  • “Orders reference only the customer’s ID — they do not contain the entire customer”

Problem with Naive Implementation: If OrderLine is managed as an independent entity, it can be directly created or deleted outside the Aggregate boundary. If Product references the entire Tag, tag changes require loading Products, creating unnecessary coupling.

Design Decision: Distinguish between child entities and cross-references.

  • Ownership relationship (OrderLine -> Order): OrderLine is modeled as Entity<OrderLineId>, existing only in Order’s internal private List<OrderLine>. Only IReadOnlyList<OrderLine> is exposed externally. OrderLine creation is only possible during Order.Create().
  • ID reference relationship (TagId -> Product): Product manages List<TagId>. Since it does not reference the entire Tag entity, changes to the Tag Aggregate do not affect Products. Managed via AssignTag()/UnassignTag() with idempotency guaranteed.
  • Cross-Aggregate reference (CustomerId -> Order): Order holds CustomerId as a value. Since it does not directly reference the Customer Aggregate, transaction boundaries are separated.

Result:

Relationship TypeImplementationAccess Approach
OrderLine -> OrderEntity<OrderLineId>, private collectionIReadOnlyList exposure
TagId -> ProductList<TagId>, ID reference onlyAssignTag()/UnassignTag() idempotent
CustomerId -> OrderHeld as valueCross-Aggregate ID reference
ProductId -> OrderLineHeld as valueCross-Aggregate ID reference
ProductId -> InventoryHeld as valueCross-Aggregate ID reference

Having defined all invariants within a single aggregate, we now design how to guarantee rules that span multiple aggregates.

Constraints requiring validation of rules across multiple Aggregates.

Business Rules:

  • “The order amount must not exceed the customer’s credit limit”
  • “Existing orders and the new order must be summed and within the credit limit”
  • “Customers with the same email cannot be registered in duplicate”
  • “Products with the same name cannot be registered in duplicate (excluding self during updates)”

Problem with Naive Implementation: If a single Aggregate directly queries another Aggregate’s state internally, the Aggregate boundary is broken. If Repositories are called directly from the domain model, infrastructure dependencies infiltrate.

Design Decision: Separate Domain Service and Specification by role.

  • Domain Service (IDomainService): Receives data from multiple Aggregates and performs business logic. The Application Layer queries the necessary Aggregates and passes them, and the Domain Service executes only pure domain logic. OrderCreditCheckService receives Customer and Order (or Money) and validates the credit limit.
  • Specification (ExpressionSpecification<T>): Encapsulates query conditions for a single Aggregate as Expression<Func<T, bool>>. Since EF Core automatically translates to SQL, domain rules are consistently applied down to the database level. CustomerEmailSpec, ProductNameUniqueSpec, ProductNameSpec, ProductPriceRangeSpec, and InventoryLowStockSpec fall into this category.

Domain Service vs Specification Decision Criteria:

CriteriaDomain ServiceSpecification
Number of Aggregates involved2 or more1
Data access patternApplication Layer queries then passesRepository queries via Expression
Return typeFin<Unit> (pass/fail)Expression<Func<T, bool>>
Representative caseCredit limit validationEmail duplicate, product name duplicate

Result:

RuleImplementationType
Credit limit validationOrderCreditCheckService : IDomainServiceDomain Service
Customer email duplicateCustomerEmailSpec : ExpressionSpecification<Customer>Specification
Product name duplicate (excluding self)ProductNameUniqueSpec : ExpressionSpecification<Product>Specification
Product name searchProductNameSpec : ExpressionSpecification<Product>Specification
Price range filterProductPriceRangeSpec : ExpressionSpecification<Product>Specification
Low stock filterInventoryLowStockSpec : ExpressionSpecification<Inventory>Specification

Having addressed cross-aggregate rules, we must now ensure data integrity under concurrent access.

Constraints requiring that data integrity is guaranteed under concurrent access.

Business Rules:

  • “Inventory deduction occurs with every order, and multiple orders can deduct the same product’s inventory simultaneously”
  • “Deduction must fail if inventory is insufficient”

Problem with Naive Implementation: Without concurrency control, if two orders simultaneously read the same inventory and each deducts and saves, one deduction is lost (lost update). Pessimistic locking becomes a performance bottleneck.

Design Decision: Apply optimistic concurrency control (IConcurrencyAware). Inventory manages byte[] RowVersion, and if RowVersion does not match during save, EF Core raises DbUpdateConcurrencyException. This is more efficient than pessimistic locking for high-frequency updates, and the Application Layer can apply retry strategies on conflict.

Why Inventory was separated from Product: Product information (name, description, price) change frequency and inventory change frequency differ significantly. If bundled into one Aggregate, every inventory deduction would lock product information, and every product information change would lock inventory. Separating them allows independent changes/locking/concurrency control for each.

Result:

  • Inventory: AggregateRoot<InventoryId> + IAuditable + IConcurrencyAware
  • byte[] RowVersion: Optimistic concurrency token
  • DeductStock(): Returns Fin<Unit> failure on insufficient stock
  • AddStock(): Always succeeds -> returns Inventory

The comprehensive structure of 5 Aggregates with their Value Objects and relationships.

The following diagram shows the comprehensive domain model structure of 5 aggregates with their value objects and relationships. Solid arrows represent ownership relationships, and dashed arrows represent ID references.

InvariantTypeGuarantee MechanismRelated Aggregate
Customer name 100 chars or lessSingle ValueCustomerName: NotNull -> NotEmpty -> MaxLength(100) -> TrimCustomer
Email format/320 charsSingle ValueEmail: NotNull -> NotEmpty -> MaxLength(320) -> Regex -> Trim + lowercaseCustomer
Product name 100 chars or lessSingle ValueProductName: NotNull -> NotEmpty -> MaxLength(100) -> TrimProduct
Product description 1000 chars or lessSingle ValueProductDescription: NotNull -> MaxLength(1000) -> TrimProduct
Tag name 50 chars or lessSingle ValueTagName: NotNull -> NotEmpty -> MaxLength(50) -> TrimTag
Shipping address 500 chars or lessSingle ValueShippingAddress: NotNull -> NotEmpty -> MaxLength(500) -> TrimOrder
Money must be positiveSingle ValueMoney: Positive validationShared (Customer, Product, Order)
Quantity must be 0 or moreSingle ValueQuantity: NonNegative validationShared (OrderLine, Inventory)
LineTotal = UnitPrice * QuantityStructuralAuto-computed in OrderLine.Create()Order
TotalAmount = Sum(LineTotals)StructuralAuto-computed in Order.Create()Order
Order lines >= 1StructuralEmpty list rejected in Order.Create()Order
Order line quantity >= 1Structural0 or less rejected in OrderLine.Create()Order
Order status transition rulesState TransitionOrderStatus Smart Enum + CanTransitionTo()Order
Blocked updates on deleted productsLifecycleDelete guard in Product.Update()Product
Delete/restore idempotencyLifecycleDelete()/Restore() conditional execution after state checkProduct
Order lines depend on ordersOwnershipprivate List<OrderLine> + IReadOnlyList exposureOrder
Tags referenced by ID onlyOwnershipList<TagId> (no entity reference)Product
Cross-Aggregate ID referencesOwnershipCustomerId, ProductId held as valuesOrder, OrderLine, Inventory
Credit limit validationCross-AggregateOrderCreditCheckService : IDomainServiceCustomer + Order
Email uniquenessCross-AggregateCustomerEmailSpec : ExpressionSpecificationCustomer
Product name uniquenessCross-AggregateProductNameUniqueSpec : ExpressionSpecificationProduct
Inventory concurrency guaranteeConcurrencyIConcurrencyAware + byte[] RowVersionInventory
Insufficient stock validationConcurrencyQuantity comparison in DeductStock() -> Fin<Unit>Inventory

The 7 invariant types guarantee domain rules at different levels. Single value invariants guarantee the validity of individual fields, structural invariants guarantee consistency between fields, and state transition invariants control changes over time. Lifecycle and ownership invariants protect aggregate boundaries, while cross-aggregate invariants and concurrency invariants guarantee system-wide integrity. Thanks to this layered protection structure, no code path can lead to an invalid state.

The implementation of these strategies using C# and Functorium DDD building blocks is covered in the code design section.