3. Domain modeling
The core building blocks of a bounded context: aggregate roots (and their header modifiers), valueobjects, nested entity parts joined via contains, events, enums, the field grammar — defaults, inline checks, access modifiers, and sensitivity tags — the set-level unique (…) invariant, and the application-layer commandHandler / queryHandler pair. Reach for this chapter when you need to know exactly what a declaration emits: which table, which DTO, which fields cross the wire.
Grammar:
Aggregate,ValueObject,EntityPart,EventDecl,EnumDecl,Property,Containment,FieldAccess,SensitivityClause,Unique,CommandHandler,QueryHandler· Validators:loom.bare-aggregate-in-type,loom.cross-aggregate-entity-part,loom.entity-field-modifier,loom.entity-field-optional-collection,loom.token-nullable,loom.field-default-not-constant,loom.version-field-collision,loom.unique-unknown-field/-duplicate-column/-collection-field/-valueobject-field/-on-event-sourced/-missing-tenant-scope,loom.shape-on-event-sourced,loom.duplicate-field,loom.duplicate-enum-value,loom.valueobject-shadows-root,loom.enum-shadows-root,loom.query-handler-saves,loom.command-handler-multi-aggregate,loom.handler-missing-body/loom.extern-handler-has-body,loom.route-handler-unresolved· scope provider insrc/language/ddd-scope.ts· Docs:../language.md,../capabilities.md
Every backend reads the same fully-resolved IR, so the table layout is structurally identical across platforms — only the host-language member casing and idiom differ. The tabs below are real generated output; one system fixture with a node, dotnet, java, python, and elixir deployable over the same context (the Order aggregate carries with crudish so the create/update wire exists) produces every tab.
aggregate
An aggregate is a consistency root. It owns a table, gets a synthetic Name id primary key automatically (you never declare id), and gets a repository. The id type is always guid (UUIDv7-minted) and is not configurable. The header has three regions:
(abstract)? aggregate Name (extends Base)?
( persistedAs: eventLog|state | shape: relational|embedded|document
| inheritanceUsing: sharedTable|ownTable | crossTenant | audited )* // order-independent
(with Cap, macro(...))? { members }
- prefix —
abstract(what the declaration is; see Inheritance, together withextendsandinheritanceUsing:). - header region, after the name, any order —
persistedAs:the truth kind (defaultstate),shape:the physical layout (defaultrelational;embeddedfolds contained parts into JSONB columns,documentstores the whole aggregate as one(id, data, version)JSONB row —shape:on an event-sourced aggregate is rejected,loom.shape-on-event-sourced),crossTenant(opts out of the tenant filter under atenancy bysystem, Tenancy), andaudited(every public command records anaudit_recordsrow, Observability & provenance). with— capabilities and macros mixed in (Capabilities, Macros).
context Orders {
aggregate Order {
reference: string
}
}
The implicit id is a branded/strongly-typed key (guid → UUID), the root gets a private constructor + a public create(...) factory + a _create(state) rehydrator, and a table is emitted with id as the primary key.
A public create(...) factory is not a public POST route. Declaring an aggregate gets you a table and read routes; it does not get you a way to put a row in one. Unless something in the model actually constructs it — a declared create, with crudish, an Agg.create({ … }) in a workflow or commandHandler, a seed row, or an apply fold — POST /api/<plural> answers 405 and the table can only ever be empty. That is legal (a table fed by a migration or an out-of-band importer is a real design), so it is reported as advice rather than a warning: loom.aggregate-not-constructible, under the Suggestions: heading, never affecting the exit code. Creating a SUBTYPE writes its base's row, so a base with a constructible subtype is not flagged.
// db/schema.ts — every context is its own Postgres schema
export const ordersSchema = pgSchema("orders");
export const orders = ordersSchema.table("orders", {
id: uuid("id").primaryKey(),
reference: text("reference").notNull(),
version: integer("version").notNull(), // implicit — see below
});
// domain/ids.ts — id is a branded string, minted by newOrderId()
export type OrderId = string & { readonly __brand: "OrderId" };
export const newOrderId = (): OrderId => uuidv7() as OrderId;
// Domain/Orders/Order.cs
public sealed class Order
{
public OrderId Id { get; private set; }
public string Reference { get; private set; } = default!;
// private ctor + State rehydrator + Create factory …
public static Order Create(string reference)
{
var e = new Order();
e.Id = new OrderId(Guid.CreateVersion7());
// …
}
}
// domain/ids/OrderId.java — an @Embeddable record; the table key is a UUID
public record OrderId(UUID value) implements Serializable {
public static OrderId newId() {
return new OrderId(Generators.timeBasedEpochGenerator().generate());
}
}
# lib/api_elixir/orders/order.ex
@primary_key {:id, UUIDv7, autogenerate: true}
@schema_prefix "orders"
schema "orders" do
field :reference, :string
field :version, :integer, default: 1
end
Every state aggregate is optimistically versioned by default. The macro expander splices the built-in versioned capability (version: int token = 1) onto every aggregate that is not persistedAs: eventLog and does not already carry it, so every table gets version INTEGER NOT NULL DEFAULT 1, every save is a guarded UPDATE … WHERE id = $1 AND version = $2, and a stale write answers 409. The version field rides the wire as a token (read + echoed back). Declaring your own version field is allowed only as version: int — any other type collides (loom.version-field-collision: field 'version' on aggregate 'Order' collides with Loom's optimistic-concurrency column, which is an 'int'). See ../capabilities.md.
A shape: document aggregate keeps the same repository surface over a blob table:
CREATE TABLE "orders"."docs" (
"id" UUID NOT NULL,
"data" JSONB NOT NULL,
"version" INTEGER NOT NULL,
PRIMARY KEY ("id")
);
valueobject
A valueobject is an immutable record with no identity, no table, and no repository of its own. It persists inside its owning aggregate — relationally as flattened, prefixed columns (total: Money → total_amount, total_currency), and on the wire as a nested object. Members are Property / derived / invariant / function / a nested test — never operations (a VO has no lifecycle). A VO may not share a name with an aggregate in the same context (loom.valueobject-shadows-root).
context Orders {
enum Currency { USD, EUR, GBP }
valueobject Money {
amount: decimal
currency: Currency
}
aggregate Order {
total: Money
}
}
total: Money flattens into prefixed root columns; the type emits as an immutable record / embeddable.
// domain/value-objects.ts — readonly fields, no setters
export class Money {
readonly amount: number;
readonly currency: Currency;
constructor(amount: number, currency: Currency) {
this.amount = amount;
this.currency = currency;
}
}
// db/schema.ts — flattened, prefixed onto the owner's table
total_amount: numeric("total_amount").notNull(),
total_currency: currencyEnum("total_currency").notNull(),
// Domain/ValueObjects/Money.cs — record with init-only props
public sealed record Money
{
public decimal Amount { get; init; }
public Currency Currency { get; init; }
public Money(decimal amount, Currency currency) { Amount = amount; Currency = currency; }
private Money() { Amount = default!; Currency = default!; } // EF/serializer ctor
}
// domain/valueobjects/Money.java — JPA-embeddable record
@Embeddable
@ValueObject
public record Money(BigDecimal amount, Currency currency) {}
// features/orders/Order.java — the owner remaps the embeddable's columns
@AttributeOverride(name = "amount", column = @Column(name = "total_amount"))
@AttributeOverride(name = "currency", column = @Column(name = "total_currency"))
Money total;
# app/domain/value_objects.py — frozen dataclass
@dataclass(frozen=True)
class Money:
amount: float
currency: Currency
# lib/api_elixir/orders/order.ex — a VO is a :map field on the owner's schema
field :total, :map
# lib/api_elixir_web/api/schemas/money.ex — the wire shape is an OpenApiSpex schema
OpenApiSpex.schema(%{
title: "Money", type: :object,
properties: %{amount: %OpenApiSpex.Schema{type: :number, format: :double},
currency: ApiElixirWeb.Api.Schemas.Currency},
required: [:amount, :currency]
})
Elixir divergence. The other four backends emit a dedicated VO type and flatten its columns (
total_amount/total_currency); the Elixir backend stores the VO as a single:map(JSONB) columntotal— in its Ecto schema and in its Ecto migration (add :total, :map). The wire (a nested{ amount, currency }object) is the cross-backend contract; the column layout is not.This holds for every value object —
mapTypeToEcto(src/generator/elixir/vanilla/schema-emit.ts) returns":map"for avalueobjectunconditionally. There is noembedded_schemafor a composite VO and no customEcto.Typefor a single-field one; a VO carrying acheckgets a schemaless-changeset validator module beside the owner (a@typesmap +cast/3+ the invariant validators +new/1), and an unconstrained VO gets no module at all.generators.mdclaimed theembedded_schema/Ecto.Typesplit in two places until 2026-09-10; both rows now point back here.
entity parts & contains
An entity part is a child entity with its own identity that has no independent existence — it lives only as a member of its aggregate. You declare the part inline, then bind it with contains <name>: <Part>[] (collection), <Part> (single), or <Part>? (optional); a plain field typed with the part (lines: Line[]) is the same containment with the keyword inferred. A containment carries only a name, [] and ? — the value-property modifiers (provenanced, an access modifier, = default, sensitive(...), check) are rejected on it (loom.entity-field-modifier), and []? is rejected because an empty collection already encodes absence (loom.entity-field-optional-collection). The part gets its own child table keyed back to the parent via a <parent>_id foreign key with ON DELETE CASCADE and an index. A part may carry its own Property / check / invariant / derived / function / nested contains; a part declared in one aggregate cannot be contained by another (loom.cross-aggregate-entity-part). Nesting must form a TREE: two parts that contain each other (directly or through a longer chain) describe a graph with no bottom, which can be neither hydrated nor persisted, so the cycle is refused at the contains clause that closes it and the loop is named (loom.containment-cycle, X → Y → X). Use <Aggregate> id for a reference where ownership is not what you mean.
context Orders {
aggregate Order {
reference: string
entity Line {
sku: string
qty: int check qty > 0
}
contains lines: Line[]
}
}
Line becomes its own table; qty > 0 becomes a domain invariant on the Line's constructor; the collection is owned (cascade-deleted with the parent).
// db/schema.ts — child table, FK to parent, cascade + index
export const lines = ordersSchema.table("lines", {
id: uuid("id").primaryKey(),
parentId: uuid("order_id").notNull().references(() => orders.id, { onDelete: "cascade" }),
sku: text("sku").notNull(),
qty: integer("qty").notNull(),
}, (table) => ({
lineOrderIdIdx: index("lines_order_id_idx").on(table.parentId),
}));
// domain/order.ts — the check lowers to an invariant on Line's ctor
private _assertInvariants(): void {
if (!(this._qty > 0)) throw new DomainError("Invariant violated: qty check qty > 0");
}
// Order keeps an owned, read-only collection:
get lines(): readonly Line[] { return this._lines; }
// Domain/Orders/Line.cs — own entity, parent id, check → AssertInvariants
public sealed class Line
{
public LineId Id { get; private set; }
public OrderId ParentId { get; private set; }
public string Sku { get; private set; } = default!;
public int Qty { get; private set; } = default!;
private void AssertInvariants()
{
if (!(this.Qty > 0)) throw new DomainException("Invariant violated: qty check qty > 0");
}
}
// Domain/Orders/Order.cs — owned collection, exposed read-only
private readonly List<Line> _lines = new();
public IReadOnlyList<Line> Lines => _lines.AsReadOnly();
// features/orders/Order.java — owned, eagerly-loaded, orphan-removed
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER)
@JoinColumn(name = "order_id", nullable = false)
List<Line> lines = new ArrayList<>();
# app/domain/order.py — the check on Line's constructor
def _assert_invariants(self) -> None:
if not (self._qty > 0):
raise DomainError("Invariant violated: qty check qty > 0")
# lib/api_elixir/orders/order.ex
has_many :lines, ApiElixir.Orders.Line, foreign_key: :order_id, on_replace: :delete
# lib/api_elixir/orders/line.ex
@derive {Jason.Encoder, only: [:id, :sku, :qty]}
schema "lines" do
field :sku, :string
field :qty, :integer
belongs_to :order, ApiElixir.Orders.Order, foreign_key: :order_id, type: :binary_id
end
Elixir honest gap. The part-level
check qty > 0is enforced on node / dotnet / java / python (a constructor guard); the ElixirLine.changeset/2onlycasts[:sku, :qty]and emits novalidate_*for it. Root-level checks andinvariants do reach the changeset — the gap is specific toentityparts.
The generated SQL makes the parent/child shape explicit (node migration shown; the same DDL is rendered for every backend):
CREATE TABLE "orders"."lines" (
"id" UUID NOT NULL,
"order_id" UUID NOT NULL,
"sku" TEXT NOT NULL,
"qty" INTEGER NOT NULL,
PRIMARY KEY ("id"),
FOREIGN KEY ("order_id") REFERENCES "orders"."orders" ON DELETE CASCADE
);
CREATE INDEX "lines_order_id_idx" ON "orders"."lines" ("order_id");
Cross-aggregate links are not containment — they are
X idreference fields (productId: Product id), enforced byloom.bare-aggregate-in-type(References across aggregate boundaries need an id link — write 'Customer id' (or 'Customer id[]' for many-to-many)). See Type system forX idandX id[]reference collections.
event
An event is a flat record of named fields (Property only — no body, no methods) that an aggregate raises with emit inside an operation. It emits as a typed event record; the per-context union (DomainEvent / IDomainEvent) collects every event so the repository can drain and dispatch them.
context Orders {
event OrderPlaced {
orderId: string
total: decimal
}
}
// domain/events.ts — interface + a "type" tag + the context union
export interface OrderPlaced {
readonly type: "OrderPlaced";
readonly orderId: string;
readonly total: number;
}
export type DomainEvent = OrderPlaced;
// Domain/Events/OrderPlaced.cs — record implementing the marker interface
public sealed record OrderPlaced(string OrderId, decimal Total) : IDomainEvent;
// domain/events/OrderPlaced.java
@org.jmolecules.event.annotation.DomainEvent
public record OrderPlaced(String orderId, BigDecimal total) implements DomainEvent {}
# app/domain/events.py — frozen dataclass with a class-level type tag
class OrderPlaced:
type: ClassVar[str] = "OrderPlaced"
order_id: str
total: float
DomainEvent = OrderPlaced
# lib/api_elixir/orders/events/order_placed.ex
defmodule ApiElixir.Orders.Events.OrderPlaced do
defstruct [:order_id, :total]
@type t :: %__MODULE__{order_id: String.t(), total: Decimal.t()}
end
Raising one (emit OrderPlaced { … }) and the apply(e: OrderPlaced) { … } folding pass are covered in Behavior & statements; transporting one across deployables is channel.
enum
An enum is a closed set of bare-identifier values, referenced bare in expressions and defaults (status := Confirmed). It emits as a native enum on every backend and as a TEXT column for the DB layer on every backend (mapTypeToColumn maps enum → text; node keeps the literal union via text(col, { enum: … }), .NET via HasConversion<string>(), java via @Enumerated(STRING), Ecto via Ecto.Enum) — members re-quoted into string literals (the source USD arrives at the compiler as the 3-char string USD; see Lexical structure §Literals). Duplicate members are loom.duplicate-enum-value; an enum may not share a name with an aggregate (loom.enum-shadows-root).
context Orders {
enum Currency { USD, EUR, GBP }
aggregate Order {
currency: Currency
}
}
// domain/value-objects.ts — const object + literal-union type
export const Currency = { USD: "USD", EUR: "EUR", GBP: "GBP" } as const;
export type Currency = "USD" | "EUR" | "GBP";
// db/schema.ts — bare members re-quoted into the column's value tuple
export const currencyValues = ["USD", "EUR", "GBP"] as const;
// …and the column itself, TEXT with the literal union kept on the TS side:
// currency: text("currency", { enum: currencyValues }).notNull(),
// Domain/Enums/Currency.cs — a real CLR enum
public enum Currency { USD, EUR, GBP }
// Infrastructure/Persistence/Configurations/OrderConfiguration.cs — stored as its string name
builder.Property(x => x.Currency).HasConversion<string>().HasColumnName("currency");
// domain/enums/Currency.java
public enum Currency { USD, EUR, GBP }
// features/orders/Order.java
@Enumerated(EnumType.STRING)
@Column(name = "currency")
Currency currency;
# app/domain/value_objects.py
class Currency(StrEnum):
USD = "USD"
EUR = "EUR"
GBP = "GBP"
# lib/api_elixir/orders/order.ex — an inline Ecto.Enum; members keep their case
field :currency, Ecto.Enum, values: [:USD, :EUR, :GBP]
# lib/api_elixir_web/api/schemas/currency.ex
OpenApiSpex.schema(%{title: "Currency", type: :string, enum: ["USD", "EUR", "GBP"]})
Member names are kept verbatim on every backend (USD on the wire everywhere; the shared DDL stores the column as TEXT).
Bare values across two enums
A bare value is resolved from the site's expected type — a field or parameter default, a := target, or either side of a comparison. Two enums may therefore share a member name, and each use picks the enum its site asks for. Where no site supplies a type, Loom refuses rather than guessing: loom.ambiguous-enum-value. Write the value qualified, or rename one member.
context Billing {
enum OrderStatus { Draft, Confirmed }
enum InvoiceStatus { Draft, Issued, Paid }
aggregate Invoice with crudish {
status: InvoiceStatus = Draft // OK — the field's type picks InvoiceStatus
derived isDraft: bool = status == Draft // OK — the comparison's left side picks it
operation touch() { let x = Draft label := "x" } // refused — an untyped `let` supplies nothing
}
}
error loom.ambiguous-enum-value
bare enum value 'Draft' is declared by more than one enum in scope
('OrderStatus', 'InvoiceStatus'), and this use has no expected type to choose
between them. Write it qualified — 'OrderStatus.Draft' or 'InvoiceStatus.Draft'
— or rename one of the values.
Guessing here is not a cosmetic difference: a first-wins pick lowers to a comparison between two different enums, which tsc accepts (string-literal unions), mypy flags as a non-overlapping equality check, and javac/csc refuse to build — four answers from one mis-resolved IR node.
Fields (Property)
A field is name: Type [provenanced | sensitive(...) | access]* [= default] [check Expr [message "…"]] [mask unless Expr] — the three flag-like modifiers parse in any order; the default, the check, and the mask stay after them, in that order. A = default value seeds the field when the client omits it; check Expr is a per-field validation predicate lowered to an invariant. (provenanced is covered in ../provenance.md; mask unless — the read-side redaction gate — in Auth.)
context Orders {
aggregate Order with crudish {
note: string = "pending" // default
reference: string // required, no default
}
}
The default surfaces in the input DTO (the field becomes optional, defaulting server-side) and as the value seeded when absent.
// http/order.routes.ts — default lands on the zod input schema
const CreateOrderRequest = z.object({
reference: z.string(),
note: z.string().default("pending"),
// …
});
// domain/order.ts — and the factory coalesces it
static create(input: { reference: string; note?: string; /* … */ }): Order {
return new Order({ id: Ids.newOrderId(), reference: input.reference, note: input.note ?? "pending", /* … */ });
}
// Application/Orders/Requests/OrderRequests.cs — default on the record param
public sealed record CreateOrderRequest(
[Required(AllowEmptyStrings = true)] string Reference,
/* … */
string Note = "pending");
// Domain/Orders/Order.cs — Create(…, string? note = null) { e.Note = note ?? "pending"; }
# app/http/order_routes.py — pydantic default
class CreateOrderRequest(BaseModel):
reference: str
note: str = "pending"
# lib/api_elixir/orders/order.ex — default on the Ecto field …
field :note, :string, default: "pending"
# lib/api_elixir/orders/order_changeset.ex — … and on the create changeset
|> __default(:note, "pending")
The example above is a constant default, so it lands on the wire as a
serializer default (zod .default(...), a C# record = …, a pydantic default, an
Ecto default:). An ambient default the client can't precompute — now() or
currentUser.<claim> — is instead applied per-request in the create path:
the field is wire-optional and the create handler coalesces the value
(body.createdAt ?? now()), because a serializer default is evaluated once at
module load and would freeze every row to the server's boot time. See
Server-sourced defaults.
A default that reads a sibling field (total: int = qty * 2) is rejected —
loom.field-default-not-constant (a field default is evaluated where no instance
exists yet — notably the create-request wire schema); write a derived instead.
A check on a field is exactly the inline form of a member invariant — both lower to the constructor guard shown under contains above (qty: int check qty > 0 → if (!(this._qty > 0)) throw …). See Invariants, derived & functions for the full invariant surface.
Field names a generated runtime reserves
A field name is any identifier, but a few collide with names a generated runtime owns:
__name__-shaped names (__meta__,__struct__, …) are refused on every backend —loom.dunder-field-name. Elixir refuses__struct__in a struct definition, Ecto defines__meta__on every schema, and pydantic does not treat a dunder annotation as a field at all.inserted_at/updated_at(orinsertedAt) are the columns Ecto's bundledtimestamps()defines. On an aggregate root the Elixir backend dropstimestamps()from the schema and its migration, and the declared field owns the column. On a relational entity part hosted onplatform: elixirthe name — andupdatedAt, whose column isupdated_at— is refused,loom.elixir-part-timestamp-field. A part'screatedAtis an ordinary column.
Elixir reserved words (end, after, fn, do, …) are fine as field and
parameter names. The Elixir backend binds the local as end_ and keeps the wire
key and column as end.
Access modifiers
Every field carries an access modifier governing its role across three shapes: the create input, the update wire, and the read projection. editable (the default, no keyword) is full client read+write. The five explicit modifiers:
| Modifier | Read (response) | Create input | Update wire | Stored |
|---|---|---|---|---|
editable (default) |
✓ | ✓ | ✓ | ✓ |
immutable |
✓ | ✓ | ✗ (off the update input) | ✓ |
managed |
✓ | ✗ (server seeds) | ✗ | ✓ |
token |
✓ | ✗ | ✓ (echoed, like id) |
✓ |
internal |
✗ (never via API) | ✗ | ✗ | ✓ (projections may read) |
secret |
✗ (never disclosed) | ✓ | ✓ (write-only) | ✓ |
The synthetic id and the implicit version are token; a token field must be non-nullable (loom.token-nullable). managed fields are server-seeded in the create factory (datetime → now, int → 0); secret and internal are dropped from the read projection.
Every ✗ above is a WIRE fact. immutable reads "absent from the update
input", not "never changes" — nothing stops a domain operation from assigning
an immutable field, on any backend. That makes it the modifier for a field
whose only legitimate writer is a guarded operation, which is otherwise easy to
miss: an author who wants "only approve() may move this" will not reach for a
word that says the field never changes.
aggregate Claim with crudish {
status: ClaimStatus immutable // off the generic update's input …
description: string
operation approve() {
requires currentUser.permissions.contains(permissions.claimsApprove)
precondition status == UnderReview
status := Approved // … but this still assigns it
}
}
// generated: api/domain/claim.ts (node; the other four backends are the same shape)
public approve(): void {
if (!(this._status === ClaimStatus.UnderReview)) throw new DomainError("Precondition failed: status == UnderReview");
this._status = ClaimStatus.Approved; // immutable ≠ unassignable
}
public update(description: string): void { // `status` is GONE from the update surface
this._description = description;
}
Drop the modifier and status is a writable update field like any other, so
POST /claims/{id}/update {"status":"Approved"} sets it at whatever gate the
update carries — skipping both the requires on approve() and its
precondition. The compiler does not decide this for you (a field with no
modifier is declared editable), but it flags the case: the warning
loom.update-gate-suggestion names the field, the gated operation, and this
remedy. It fires for any gate on the operation — requires, a when state
gate, or a precondition reading the field and no parameter — so a when-only state machine
with no auth is caught too, and for those it adds that managed = <default>
keeps the field off create as well.
context Orders {
aggregate Order with crudish {
reference: string // editable (default)
slug: string immutable // set once at creation
createdAt: datetime managed // server stamps it; not client-writable
version: int token // the optimistic-concurrency counter, spelled out
couponCode: string secret // accepted on write; never sent back
internalScore: int internal // hidden from clients
}
}
The create input drops managed / token / internal (createdAt, version, internalScore) but keeps secret and immutable (couponCode, slug):
// domain/order.ts — create takes only the client-writable fields;
// managed/token/internal are seeded server-side
static create(input: { reference: string; /* total, currency, note? */ slug: string; couponCode: string; email: string }): Order {
return new Order({
id: Ids.newOrderId(),
// …
createdAt: new Date(), // managed datetime → now()
version: 0, // token int → 0
internalScore: 0, // internal int → 0
// …
});
}
// Domain/Orders/Order.cs — Create signature omits managed/token/internal
public static Order Create(string reference, Money total, Currency currency, string slug, string couponCode, string email, string? note = null)
// CreateOrderRequest record likewise: CouponCode + Slug present; CreatedAt/Version/InternalScore absent
# app/http/order_routes.py
class CreateOrderRequest(BaseModel):
reference: str
# …
couponCode: str # secret: accepted on input
slug: str # immutable: set on create
email: str
# createdAt / version / internalScore are NOT accepted
# lib/api_elixir/orders/order_changeset.ex — the update seam casts only the
# editable set: no slug (immutable), no version (token), no internal_score
@update_fields [:reference, :total, :currency, :note, :coupon_code, :email]
The read projection drops secret and internal (couponCode, internalScore) but keeps managed / token / immutable — on all five backends:
// db/repositories/order-repository.ts — toWire() omits couponCode + internalScore
toWire(root: Order): unknown {
return { id: root.id as string, reference: root.reference, /* total, currency, note */
slug: root.slug, createdAt: (root.createdAt as Date).toISOString().replace(/\.?0+Z$/, "Z"),
version: root.version, email: root.email, lines: /* … */ };
}
// Application/Orders/Responses/OrderResponses.cs — no CouponCode, no InternalScore
public sealed record OrderResponse(
[property: Required] Guid Id, [property: Required] string Reference, /* … */
[property: Required] string Slug, [property: Required] string CreatedAt, [property: Required] int Version,
[property: Required] string Email, [property: Required] IReadOnlyList<LineResponse> Lines);
// features/orders/OrderResponse.java — same omission
public record OrderResponse(UUID id, String reference, MoneyResponse total, Currency currency, String note,
String slug, String createdAt, int version, String email, List<LineResponse> lines) { /* from(Order) */ }
# app/http/order_routes.py — OrderResponse omits couponCode + internalScore
class OrderResponse(BaseModel):
id: str
reference: str
# …
slug: str
createdAt: str
version: int
email: str
lines: list[LineResponse]
# lib/api_elixir_web/controllers/order_controller.ex — serialize/1 lists the wire fields
defp serialize(record) do
%{"id" => record.id, "reference" => record.reference, "total" => serialize_money(record.total),
"currency" => record.currency, "note" => record.note, "slug" => record.slug,
"createdAt" => record.created_at, "version" => record.version, "email" => record.email,
"lines" => Enum.map(record.lines || [], &serialize_line/1)}
end
A server-owned field must have a value
managed / internal / token take a field off the create input, so the server owns it — but "the server owns it" is only half a contract: something has to WRITE it. A non-optional server-owned field is constructible when the language has an absent value for its type, or when the model supplies one:
| the field is | constructible because |
|---|---|
datetime / int / long / decimal / bool / string / guid |
the type has a language-defined absent value (now, 0, false, "") that every backend's create factory seeds |
| a collection | absent is the empty collection |
= <default> |
the declared default is materialized at construction |
written by a stamp onCreate / stamp onUpdate |
persist-time supplies it |
T? |
absent is null, declared |
Everything else is refused as loom.unconstructible-server-field — money, json, an enum (no member is privileged), a value object, an X id. There is no input anywhere that gives the field a value, so the aggregate cannot be created:
context Billing {
aggregate Invoice with crudish {
reference: string
total: money managed // refused — no client param, no default, no stamp
}
}
error loom.unconstructible-server-field
aggregate 'Invoice' cannot be created: field 'total' is 'managed', so it is not
on the create input, but nothing writes it — it has no '= <default>' and no
lifecycle stamp. Every create would leave it unset (a null into a NOT NULL
column). Give it a default ('total: … = <expr>'), stamp it
('stamp onCreate { total := … }'), or make it optional ('total: …?').
Seeding a value in the emitters instead would make the model compile while leaving the value fabricated, which is worse than a refusal: on node the create factory emitted total: null into a non-nullable Decimal, .NET persisted a silent 0, and java/elixir/python failed the insert against a NOT NULL column — four answers to one model.
unique (…) — the set-level invariant
unique (a, b) declares a natural key: no two rows may share the listed tuple. It cannot run in the per-instance _assertInvariants floor, so the compiler derives its enforcement — a DB unique index (partial under softDeletable; tenant-scoped under tenantOwned, or loom.unique-missing-tenant-scope) plus a per-backend 23505 unique_violation → 409 Conflict mapping. Columns are bare field names resolved against the aggregate (loom.unique-unknown-field, with a did-you-mean list; loom.unique-duplicate-column; a collection or value-object column is rejected — loom.unique-collection-field / loom.unique-valueobject-field); an event-sourced aggregate has no single table to constrain (loom.unique-on-event-sourced).
context Orders {
aggregate Order {
reference: string
unique (reference)
}
}
CREATE UNIQUE INDEX "orders_reference_uq" ON "orders"."orders" ("reference");
// http/index.ts — the app-level error boundary
if (err && typeof err === "object" && (((err as { code?: string }).code ?? (err as { cause?: { code?: string } }).cause?.code) === "23505")) {
recordDomainFault("disallowed");
return problem(409, "Conflict", "A record with these values already exists.");
}
// Api/DomainExceptionFilter.cs
if (context.Exception is Npgsql.PostgresException { SqlState: "23505" }
|| (context.Exception is Microsoft.EntityFrameworkCore.DbUpdateException due
&& due.InnerException is Npgsql.PostgresException { SqlState: "23505" }))
{
context.Result = Problem(context, 409, "Conflict", "A resource with these values already exists.", trace_id);
context.ExceptionHandled = true;
return;
}
// api/ApiExceptionAdvice.java — DataIntegrityViolationException, discriminated by SQLState
if ("23503".equals(sqlState(e))) { /* still referenced → 409 */ }
return respond(problem(409, "Conflict", "A resource with these values already exists.", request), 409);
# app/http/problem.py
@app.exception_handler(IntegrityError)
async def _integrity(request: Request, err: IntegrityError) -> JSONResponse:
sqlstate = getattr(getattr(err, "orig", None), "sqlstate", None)
if sqlstate == "23505":
return problem(request, 409, "Conflict", "A resource with these values already exists.")
# lib/api_elixir/orders/order_changeset.ex — Ecto names the index so the
# constraint error lands on the changeset (→ 409 via ProblemDetails)
|> unique_constraint(:reference, name: "orders_reference_uq")
A performance index (non-unique) is not a domain fact — it lives on the storage binding as resource … { index: [Order.reference, Line.(sku, qty)] } (APIs, storage & resources); the compiler suggests one when a find … where filters on an un-indexed column (loom.index-suggestion).
sensitive(...)
sensitive(tag1, tag2, …) tags a field with one or more information-flow classifications. Tags are bare identifiers, opaque to the compiler — pii, phi, cred, audited are conventional but any identifier is accepted. The downstream effect today: the field is redacted in the aggregate's inspect / ToString() debug form (so it never lands in logs, exceptions, or debugger watches). It is orthogonal to access — combine sensitive(...) secret for a field that is both write-only and log-redacted.
context Orders {
aggregate Order {
reference: string
email: string sensitive(pii)
}
}
The generated inspect renders email as <redacted> while other fields show their value:
// domain/order.ts (excerpt of the inspect getter)
get inspect(): string {
return "Order(" + "id: " + String(this._id) + ", "
+ "reference: " + "'" + this._reference + "'" + ", "
+ "email: " + "<redacted>" + ", " + /* … */ + ")";
}
// Domain/Orders/Order.cs (excerpt of the Inspect string)
public string Inspect => "Order(" + "id: " + this.Id.ToString() + ", "
+ "reference: " + "'" + this.Reference + "'" + ", "
+ "email: " + "<redacted>" + ", " + /* … */ + ")";
public override string ToString() => Inspect;
# lib/api_elixir/orders/order.ex — an Inspect protocol impl on the schema struct
defimpl Inspect, for: ApiElixir.Orders.Order do
def inspect(record, _opts) do
string("Order(" <> "id: " <> to_string(record.id) <> ", "
<> "reference: " <> "'" <> record.reference <> "'" <> ", "
<> "email: " <> "<redacted>" <> ", " <> # …
end
end
The field is still stored and (subject to its access modifier) still crosses the API wire — sensitive(...) governs the debug representation, not the response projection. For read-side redaction on the wire use mask unless <currentUser predicate> (Auth). See ../language.md §"Sensitivity" and ../provenance.md for the broader compliance surface.
commandHandler & queryHandler
Application-layer handlers are context members — the named command / query entry points of a context (a workflow has no handle member). Both take a parameter list and a body in the ordinary statement vocabulary (load → mutate → save → return); a queryHandler always declares a return type and may not save (loom.query-handler-saves), a commandHandler may omit it and may touch only one aggregate (loom.command-handler-multi-aggregate). An extern handler is bodyless (;) and dispatches to a scaffolded, user-owned implementation file (loom.extern-handler-has-body / loom.handler-missing-body; Externs). A handler reaches HTTP only through an explicit route on an api (route POST "/orders/close" -> Orders.CloseOrder; an unresolvable target is loom.route-handler-unresolved) — APIs.
context Orders {
aggregate Order with crudish {
reference: string
operation close() { reference := reference + "-closed" }
}
repository Orders for Order { }
command CloseOrder { orderId: Order id }
response OrderResponse { reference: string }
commandHandler CloseOrder(cmd: CloseOrder): OrderResponse {
let o = Orders.getById(cmd.orderId)
o.close()
return o
}
queryHandler GetOrder(orderId: Order id): OrderResponse {
let o = Orders.getById(orderId)
return o
}
}
// at system level:
// api OrdersApi from Sales { route POST "/orders/close" -> Orders.CloseOrder }
// http/ordersApi-routes.ts — one createRoute per `route`, body inlined
app.openapi(
createRoute({
method: "post", path: "/orders/close", tags: ["OrdersApi"], operationId: "ordersCloseOrder",
request: { body: { content: { "application/json": { schema: z.object({ orderId: z.string().uuid() }) } } } },
responses: { 200: { /* OrderResponse */ }, 400: { /* … */ }, 404: { /* … */ }, 415: { /* … */ } },
}),
async (httpCtx) => {
const body = httpCtx.req.valid("json");
const cmd = { orderId: Ids.OrderId(body.orderId) };
const orders = new OrderRepository(db, events);
const o = await orders.getById(cmd.orderId);
o.close();
await orders.save(o);
return httpCtx.json(orders.toWire(o) as z.infer<typeof OrderResponse>, 200);
},
);
// Application/Orders/Commands/CloseOrderHandler.cs — a Mediator handler
public sealed class CloseOrderHandler : ICommandHandler<CloseOrderCommand, Order>
{
public async ValueTask<Order> Handle(CloseOrderCommand command, CancellationToken cancellationToken)
{
var o = await _orders.GetByIdAsync(command.OrderId, cancellationToken)
?? throw new AggregateNotFoundException($"Order {command.OrderId} not found");
o.Close();
await _orders.SaveAsync(o, cancellationToken);
return o;
}
}
// Api/OrdersApiRoutesController.cs
[HttpPost("/orders/close")]
public async Task<IActionResult> CloseOrder([FromBody] CloseOrderBody body)
{
var result = await _mediator.Send(new CloseOrderCommand(body.OrderId));
return Ok(new OrderResponse(result.Id.Value, result.Reference, result.Version));
}
The multi-aggregate, long-running, and event-reacting forms are workflow; the stateless cross-aggregate calculator is domainService.