Skip to content

Domain

The domain layer is the stable center of a Forze service. It holds your business entities and the rules that govern them, in plain Python — no database drivers, no HTTP, no adapters. Change the database engine or the web framework and this layer doesn't move.

The rule

Domain code imports from no other layer — only Pydantic models, dataclasses, and standard Python. If it needs a database to run, it doesn't belong here.

The aggregate and its family

You model a business entity as an aggregate. Around it sits a small family of frozen, purpose-built types that carry data across boundaries.

The aggregate family: commands and mixins feed the aggregate; the aggregate projects to a read model The aggregate family: commands and mixins feed the aggregate; the aggregate projects to a read model

Type Role Base class
Aggregate The entity with identity, versioning, and rules Document
Create command Frozen input that creates one BaseDTO
Update command Frozen partial-update payload (all fields optional) BaseDTO
Read model Frozen projection returned from queries ReadDocument

How a create command becomes the domain model and is projected back to the read model — the codecs that carry it — is the mapping reference. Why these are separate models, rather than one with the rest derived, is Why four models.

CreateDocumentCmd is deprecated

CreateDocumentCmd still works as an alias for BaseDTO but is deprecated. Use BaseDTO for new create commands.

You build an aggregate by subclassing Document, which carries four built-in fields so you don't redefine identity and versioning every time:

Field Type Purpose
id UUID Identity, assigned once (frozen)
rev int Revision, bumped on each write (frozen)
created_at datetime Creation time (frozen)
last_update_at datetime Last write time

How a model writes itself in JSON

Every forze model (Document, BaseDTO, ReadDocument) writes a Decimal field in fixed point (0.00000000, not 0E-8); past an exponent of 100 either way it keeps scientific form, so a huge exponent cannot expand. A set or frozenset field is written as a list in one order in every process: sorted when its items are strings, numbers, dates, UUIDs or tuples of those, and by each item's canonical JSON text otherwise (a set of mixed types, or of sets). Lists and tuples keep their order. These apply to fields typed as such; a value inside an Any or dict[str, Any] field, or inside a plain pydantic model, is written as pydantic writes it.

Aggregates that emit events

Compose AggregateRoot alongside Document — class Order(Document, AggregateRoot) — to add an in-process event buffer. Behaviour methods record DomainEvents that the application layer drains and dispatches after the operation commits.

Rules live with the data

The payoff of a domain layer is that invariants are enforced where the data lives, not scattered across handlers. Two mechanisms attach rules to an aggregate:

  • @invariant — a check enforced on create and after every update.
  • @update_validator — a rule that runs only when an update touches relevant fields, with the before state, after state, and the diff in hand.
from forze.domain.models import Document
from forze.domain.validation import update_validator
from forze.base.exceptions import exc


class Order(Document):
    customer: str
    total: int
    status: str = "pending"

    @update_validator(fields={"total"})
    def _total_is_final_once_shipped(before, after, diff):
        if before.status == "shipped":
            raise exc.domain("A shipped order's total is final.")

Updates are structured. order.update({"total": 99}) returns a new immutable instance and a minimal diff, runs the validators, and bumps last_update_at. A patch that changes nothing returns the original and an empty diff. An update compares a naive datetime with a stored aware one as UTC, as Postgres and Mongo store a naive value (Mongo at millisecond precision), so resending the same instant is no change. On Postgres it compares a value as its column stores it: rounded to a timestamp(p), timestamptz(p), time(p), timetz(p) or numeric(p,s) column's precision or scale, or to real, and blank-padded to char(n), arrays element by element. Resending such a value is no change either.

The patch merges into what is stored: a field it names is set, and the rest are kept. Into a model (the document, a nested model, or one held in a union or a mapping) it merges field by field, and a field set to None takes its default, or is refused when it has none. Into a mapping or Any value it merges key by key: the keys the patch leaves out are kept, and a None is stored as null where the value's type admits one (a dict[str, int] refuses it). A list is replaced. An update cannot remove a key from a mapping.

These validate a single write. A state transition — its guard plus the change it makes — belongs on the aggregate too, as a decider method that returns the patch to persist; see Aggregate decisions.

Reusable concerns: mixins

Common domain concerns ship as composable mixins in forze_kits (included in the default install). Each adds one focused capability — no deep inheritance chains.

Mixin Adds
Soft deletion An is_deleted flag plus a validator that blocks edits to deleted records
Metadata name, display_name, and description with normalized string types
Number id A human-readable number_id, populated by a counter on create; a spec over it declares it unique
Creator id A frozen creator_id, injected from the current actor context

A period, and which end is in force

A span of time is four decisions — is the end included, can it be open-ended, what does overlap mean where two spans touch, and what is a zero-length span — and a codebase that leaves them to prose answers them differently in two modules. Period carries the answers in the value:

from datetime import date
from forze.base.primitives import Period

quarter = Period(start=date(2026, 1, 1), end=date(2026, 4, 1))         # bounds="[)"
contract = Period(start=date(2026, 1, 1), end=date(2026, 3, 31), bounds="[]")
current = Period(start=date(2026, 1, 1))                                # open-ended
Bounds Reads as Use it for
"[)" (default) start included, end excluded anything that must tile — consecutive periods cover a timeline with no gap and no overlap
"[]" both included periods a person wrote: "valid through 31 March"
"(]" · "()" start excluded available for completeness; an excluded start on a date grain is the shape to avoid when a database constraint has to agree

contains(at), overlaps(other) and intersects(start, end) read the convention from the value, so a shared endpoint is counted only when both sides have it in force: half-open [Jan, Feb) and [Feb, Mar) do not overlap, and the inclusive pair does. An open end reaches every point its own convention admits and no earlier one, so an open-ended period that excludes its start still does not meet a point at that start. A zero-length period is accepted — a booking cancelled in the instant it was made is a real row — and is empty unless both ends are included.

Endpoints must be comparable, and the type system cannot say so: datetime subclasses date, so a type checker passes a date/datetime pair, and a naive and an aware datetime are one type to it. Period checks both at construction — along with the endpoints being dates at all, since a period is as often built from dynamic input as from typed code — rather than letting any of them surface as a TypeError from inside a comparison later.

Wall clocks and instants

A wall-clock time and a zone name one instant on almost every day of the year. On the two days a zone changes offset they do not: a fall-back time happens twice, and a spring-forward time never. The civil-time helpers refuse both rather than pick an hour, and take the zone as a value you inject — CivilZone("Europe/Berlin"), validated where you declare it — never a module constant:

from datetime import date, datetime
from forze.base.primitives import CivilZone, elapsed_minutes, local_day_bounds, to_instant

berlin = CivilZone("Europe/Berlin")

to_instant(berlin, datetime(2026, 10, 25, 2, 30))          # refused: dst_ambiguous
to_instant(berlin, datetime(2026, 10, 25, 2, 30), fold=0)  # the first 02:30, summer time
local_day_bounds(berlin, date(2026, 10, 25))               # a 25-hour Period, bounds="[)"
Function Returns Refuses
to_instant(zone, local, fold=None) the UTC instant a naive wall time names dst_ambiguous without fold, dst_nonexistent always
local_day_bounds(zone, day) · month_bounds(zone, year, month) a half-open Period of instants a month outside 1–12
spanned_local_days(zone, start, end) the local days whose bounds [start, end) meets naive_datetime, a range ending before it starts
elapsed_minutes(start, end) whole minutes between two instants naive_datetime

A day whose midnight the zone skips starts at the first instant that exists, and a day whose midnight it repeats starts at the first one: the day bounds resolve where to_instant refuses, because a calendar with holes or overlaps is worse. A day the zone skips entirely (Samoa's 30 December 2011) is an empty Period, and spanned_local_days never lists it. Days tile: where a zone repeats the hours around a midnight, the repeat belongs to the new day, so a range split by spanned_local_days and local_day_bounds keeps all its time, once. Durations are computed on instants only: two wall-clock times across a transition are off by the shift, and the wrong answer is a plausible number. An answer that would fall outside years 1 to 9999 is refused (civil_time_out_of_range) rather than raising from inside a conversion. AwareDatetime is for your boundary models: a naive value is a validation error (naive_datetime) naming the field. Bind fold where the UI knows which occurrence the user meant; otherwise expect a dst_ambiguous on one autumn night a year.

These sit beside two other time-zone paths the framework already has — the analytics time-bucketing and the durable scheduler's cron — each shaped for its own plane. Use these for converting domain wall-clock values.

Aggregates define what your domain is and the rules it keeps; turning actions on them into something the runtime can execute is the application layer.