Domain Model
In Clean Architecture, the domain model sits at the very center β it defines the core types and their business rules. In Shopfoo, the domain model is split across two distinct locations:
Types
Shopfoo.Domain.Types/
Type definitions β shared across all layers up to the UI
Behavior
Shopfoo.Product/Model/
Aggregates β business rules and invariants (DDD)
This separation is deliberate: types must travel across all layers (from server to front-end), while the aggregate behavior β validation, invariants, domain rules β stays in the domain project, internal and close to the workflows that enforce them.
Types in Domain.Types
Domain.TypesThe Shopfoo.Domain.Types project defines all the domain types as pure data structures β records and discriminated unions β with no business logic beyond simple construction helpers and basic "getter".
π src/Core/
βββποΈ Shopfoo.Domain.Types
βββπ Common.fs β Money, Currency, SKU, FSID, ISBN, OLID
βββπ Errors.fs β Guard, Validation, Error types
βββπ Security.fs β User, Claims, Access
βββπ Translations.fs β TranslationKey, Translations
βββπ Catalog.fs β Product, Category, Book
βββπ Sales.fs β Prices, Sales
βββπ Warehouse.fs β Stock, StockEventWhy a shared types project?
The types are referenced by six projects across the entire solution:
Shopfoo.Client via Shopfoo.Shared
Shopfoo.Server
Shopfoo.Product
Shopfoo.Home
Shopfoo.Program
Shopfoo.Data
The Client (front-end Fable/Elmish project) accesses Domain.Types transitively through Shopfoo.Shared. This means the same F# types are used on both server and client β no DTO mapping or code generation is needed for the UI layer. One notable exception is the Error union: it is not used by the front-end. Instead, the Server maps domain errors to an ApiError type better suited for Client consumption.
This is possible because Domain.Types contains only Fable-compatible F# β pure types without .NET-specific dependencies. Types that require .NET-only features (like DateOnly, Reflection) are guarded behind #if !FABLE_COMPILER or are inlined by the compiler using inline keyword.
Type examples
Product (in Catalog.fs) β a core aggregate with typed identifiers and constrained fields:
Category β a discriminated union modeling the two product families:
Prices (in Sales.fs) β with a factory method enforcing construction invariants:
Stock (in Warehouse.fs) β an entity identified by its SKU:
Error model
The Errors.fs file in Domain.Types defines a comprehensive error model that spans the entire application.
The Error union
Error unionAll error kinds converge into a single discriminated union:
Each case covers a distinct error category:
GuardClause
A single field guard failure (see below)
Validation
Multiple failures collected by applicative validation (see below)
DataError
Data layer issues (not found, duplicate key, HTTP failure)
OperationNotAllowed
A business rule rejects the operation
WorkflowError
Workflow cancelled or undo failed (saga)
Bug
Unexpected exception
BusinessError
Domain-specific error (see below)
Errors
Recursive composition of multiple errors
Guard clauses
A guard clause protects a single field or value β it prevents constructing or persisting an object in an invalid state. The Guard class provides a fluent API for common checks:
Each method returns a Result β either the validated value or a GuardClauseError describing what went wrong and on which entity.
GuardCriteria bundles multiple constraints for a single string field (required, min/max length), making guard declarations declarative:
In practice, criteria are declared alongside the domain type they protect, in a dedicated Guard module, then consumed via guard.Validate in the validation function:
Validation
While a guard clause checks one field, validation aggregates multiple guard results into a single pass that reports all failures at once β not just the first one:
The validation computation expression supports applicative composition with let! ... and! ..., as illustrated in the validate function above β each branch runs independently, so if SKU and Description both fail, both errors are collected. This is essential for user-facing forms where reporting all issues at once is expected.
The Product and Prices aggregates in the Aggregates in Model/ section below provide more concrete examples of how guard clauses and validation are combined in practice.
Extensibility via IBusinessError
IBusinessErrorA discriminated union in F# is a closed set β new cases cannot be added outside the declaring module. OCaml, F#'s closest sibling, offers polymorphic variants as a native alternative: open union types whose cases can be composed and extended across modules while still supporting exhaustive pattern matching. F# deliberately chose not to adopt them, favouring simplicity and predictability of closed unions. The BusinessError of IBusinessError with the IBusinessError interface pattern below is one idiomatic way to recover extensibility within that constraint.
Any domain project can define its own business error type implementing IBusinessError and wrap it as Error.BusinessError(myError). The central Error union does not need to know about every concrete error kind β it only depends on the interface. The ErrorCategory and ErrorMessage modules consume Code and Message for logging and display, without coupling to the concrete type.
This is the Open/Closed Principle applied to F# unions: the Error type is closed to modification but open to new business error kinds through the IBusinessError interface β achieving the same extensibility that OCaml's open variants or polymorphic variants provide natively.
The idea of using an interface as the payload of a union case was inspired by Paul Blasucci's FaultReport: a Theoretical Alternative to Result. His proposal goes further: replace Result entirely with a richer type called Report, paired with an IFault interface, to address several structural weaknesses of Result. It is worth noting that in Shopfoo, the program computation expression already handles error lifting β the transparent unification of each workflow's specific error sub-type into the root Error type β which eliminates a significant class of the friction points Paul identifies. The IBusinessError pattern therefore captures the extensibility insight from his work without requiring a full departure from Result.
Aggregates in Model/
Model/In DDD, an aggregate groups an entity with its invariants β the business rules that must always hold true. Shopfoo borrows this concept from DDD but applies it selectively:
What Shopfoo retains β the aggregate as a module that owns the invariants of a domain entity. By convention, workflows delegate validation to the aggregate before persisting β though the type system does not enforce this.
What Shopfoo does not adopt β the aggregate as a transactional boundary designed for concurrency control (e.g., optimistic locking on a concert ticket sale). Shopfoo has no such contention scenario.
While all types live in Domain.Types β entities (Product, Stock, Prices), value objects (Money, Currency), and typed identifiers (SKU, ISBN, FSID) β the aggregate logic for entities is split out into the domain project's Model/ folder:
Each module corresponds to a DDD aggregate and is marked [<RequireQualifiedAccess>] β callers must write Product.validate, Prices.validate, etc. β keeping the API explicit and making each aggregate's responsibilities clearly visible.
Notice what is absent from Model/: there is no IProductRepository, IPricesRepository, or any persistence interface. In a classic DDD/OOP design, each aggregate root owns a repository interface. Shopfoo takes a different, functional approach: persistence is handled entirely in the application layer through Instructions, where each instruction represents a single function (GetPrices, SaveProduct, AddPrices...) rather than an object grouping multiple operations. This keeps Model/ purely focused on invariants, with no coupling to persistence concerns.
How workflows enforce aggregate invariants
The aggregate modules are consumed by Workflows. Any workflow that persists an aggregate calls its validation before saving, ensuring the domain stays consistent:
The pattern is always the same: validate first, persist second. If an invariant fails, the program computation expression short-circuits with a validation error β no data is written.
Product aggregate
The Product module enforces that all required fields satisfy their business constraints (non-empty, max length) using the Guard type from Domain.Types:
Key points:
Applicative validation β
let! ... and! ...collects all errors at once instead of stopping at the first failure. This is powered by thevalidationcomputation expression defined inErrors.fs.GuardCriteria β Each
Product.Guard.*value (e.g.,Product.Guard.SKU) encapsulates the rules for a field (required, min/max length). These criteria are defined alongside theProducttype inDomain.Types, keeping the rules close to the data they constrain. BecauseGuardCriteriais a plain F# record, it can also be consumed by the front-end (e.g., to drive form validation), avoiding any duplication of these rules between layers.Category-specific rules β The private
validateBookhelper adds extra invariants forBooksproducts (e.g., subtitle constraints), whileBazaarproducts pass through.
Prices aggregate
The Prices module ensures price values are positive when present:
The two private helpers handle the optionality of each price:
guardListPriceβ validates only whenListPriceisSome, otherwise returnsOk.guardRetailPriceβ validatesRegularprices but skipsSoldOut(no price to validate).
Stock aggregate
The Stock module enforces a contextual invariant: a product can only be marked as sold out when its stock quantity is zero.
Unlike Product.validate or Prices.validate which are general-purpose guards, verifyNoStock is a contextual invariant β it protects one specific business operation (MarkAsSoldOut). This illustrates that aggregate modules can contain both general validation and business rules (invariants).
Internal extension
The Extensions module adds a convenience method to the Guard type, combining Satisfies and ToValidation into a single call:
This is an F# type extension β it augments the Guard class from Domain.Types without modifying it. The internal visibility ensures this helper is only available within the Shopfoo.Product project.
Summary
Aspect
Domain.Types
Model/
DDD role
Entities + Value Objects (data only)
Aggregates (invariants, business rules)
Contains
Type definitions (records, DUs)
Validation functions, domain invariants
Visibility
Public β shared across all layers
Internal to the domain project
Fable-compatible
Yes β usable in the front-end
No β server-side only
Depends on
Shopfoo.Common only
Domain.Types
Last updated