githubEdit

cubeDomain 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:

Concern
Project
Purpose

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

The 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, StockEvent

Why 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.

circle-info

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

All error kinds converge into a single discriminated union:

Each case covers a distinct error category:

Case
Origin

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

A discriminated union in F# is a closed set β€” new cases cannot be added outside the declaring module. OCaml, F#'s closest sibling, offers polymorphic variantsarrow-up-right 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.

circle-info

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 Resultarrow-up-right. 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/

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.

circle-exclamation

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 the validation computation expression defined in Errors.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 the Product type in Domain.Types, keeping the rules close to the data they constrain. Because GuardCriteria is 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 validateBook helper adds extra invariants for Books products (e.g., subtitle constraints), while Bazaar products 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 when ListPrice is Some, otherwise returns Ok.

  • guardRetailPrice β€” validates Regular prices but skips SoldOut (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