DDD design DSL (.ddd)
Overview
Section titled “Overview”A Sculptor-style domain-driven-design layer over .mox domain models. The
domain model defines the data (entities, features, constraints); the .ddd
design defines the application architecture on top of it: the application,
its modules, the application services with their use-case operations, and
per-class design decisions (stereotype, flags, repository, state machine,
delegation targets). Parsed by phero-syntax (parse_ddd, the chumsky parser) at
compile time, lowered and validated by phero-driver
(compile_ddd_str) against the imported domains, and emitted as the
versioned phero_ir::ddd::DddModel artifact (the phero-ir crate).
The .ddd DSL lives alongside the .mox domain model as a complementary
input: a design never declares classes or features — it references the
classes the imported domains declare and records what the application makes
of them. Downstream consumers ingest the artifact exactly like the
ActorModel policy artifact; there is no in-tree backend yet.
The normative contract — entry points, resolution semantics, the validation
rules, the aggregate-derivation rule, and the capabilities
contract — is documented on phero_driver::compile_ddd_str; this page is the
language reference.
Artifact wire format
Section titled “Artifact wire format”DddModel is a standalone, versioned wire artifact following the phero-ir
wire format contract (see also the phero_ir::ddd module
docs):
- Versioned independently of the domain-model marker via
DDD_MODEL_FORMAT_VERSION(= 1), theActorModelprecedent.DddModel::from_jsonrejects absent/wrong versions withIrError::UnsupportedFormatVersion(an absent version reports as 0). - All struct field names serialize as
camelCase(formatVersion,returnType,optimisticLocking, …). Unit-only enums serialize as bare strings: stereotypes lowercase ("entity" | "value" | "dto"), built-in repository operations camelCase ("findById","findAll","findByExample","findByKeys","save","delete"). - Signatures reuse the domain IR’s
TypeRef(adjacent tagging:{"type": "class", "value": {...}}) andOperationParam({"name": ..., "type": ...}) — there are no parallel wire-level signature types. - Unknown fields are ignored on deserialize; every field added after v1
must keep the
#[serde(default)]+ skip discipline. The pherolang#176/#180 additions ride that rule: an operation’stransitionbinding (TransitionRef) and its resolved guard paths (conditionPaths,ConditionPathrows with adjacently tagged scopes) are absent from every artifact that does not declare them — body-less and machine-less models stay byte-identical,DDD_MODEL_FORMAT_VERSIONstays 1. The pherolang#179 additions follow the same discipline: a design’schildOperations(ordinaryServiceOperationrows declared byextendblocks) and an operation’schildScope(ChildRef { feature, class, action }— the root’s child feature, the child class’s full name, theDelegation.targetconvention, and the validated action"add" | "remove" | "save") appear only when authored. - Executable operation bodies (issue #178)
ride the same additive fields: a service or repository operation’s
bodiesmap holds the checked body under the"stmt"target — the canonical JSON ofphero_expr::stmt::StmtBody(an opaque string: phero-ir never depends on phero-expr, theOperation.bodies/"rune"discipline) — and a service body writtenname(...) => <Repository> { ... }also carriesbodyReceiver(the primary repository bound asself). A repository body binds its own design’s repository, so it records no receiver. Body-less operations omit both keys. - A repository operation carries exactly one of a built-in op
(
"builtin") or a declared signature (returnType/params).
Minimal artifact: an empty model serializes as exactly {"formatVersion":2}.
let compilation = phero_driver::compile_ddd_str("library.ddd", source, &domains, &phero_driver::DomainImports::default());let json = compilation.model.unwrap().to_json_pretty()?; // versioned artifactlet model = phero_ir::ddd::DddModel::from_json(&json)?; // version-gated reloadPhilosophy
Section titled “Philosophy”- Sculptor semantic identity — every Sculptor design concept has a direct DSL equivalent (see the concept-mapping table below). No lossy mapping.
- Validated against the real domain — design class references and
operation signatures type-check against the imported
.moxmodels; a typo’d class is a compile error, not a silent generation mismatch. - Resolution is validation-only — the artifact keeps every name exactly as authored; consumers resolve against the domain model.
- Loosely coupled dimensions — capability references tie the design dimension to the actor-policy dimension by name only, never by construction.
Grammar
Section titled “Grammar”The normative EBNF for the .ddd surface lives exactly once, in the parser
module docs under .ddd design
sources
(crates/phero-syntax/src/parser/mod.rs) — the single authority for ddd_file,
application_decl, module_decl, service_decl, design_decl,
repository_decl, machine_decl, search_decl, and extend_decl; this
page keeps only the language-level rules. The .ddd keyword tables in
crates/phero-syntax/src/ddd.rs are the keyword authority the parser and the
formatter share.
Lexical and structural rules:
-
Every
.dddword (application,base,module,service,extend,inject,entity,value,dto,abstract, the flags,repository, and the built-in operation names) is a contextual identifier, not a keyword: it is special only in the grammar position it is parsed in, remains usable as a class or operation name elsewhere, and can be escaped with^(theid/readonlyprecedent). -
importdeclarations must precede theapplicationdeclaration; there is exactly one application per file, andbasemust be its first member when present. -
A
///doc comment run directly above aserviceorsearchdeclaration becomes its description in the artifact. Contiguous///lines join with newlines — the same semantic as the.moxdoc collection (docs/LANGUAGE.md). -
Delegated operations (
name => target.operation) copy the target’s signature conceptually and declare none of their own; a delegated op may be namedsave(contextual keywords). Declared service operations may carry acapability a, bclause, aninvokes a.b, cclause (the decision services the operation invokes), and anemits A, Bclause; delegated service operations may carry all three clauses too (they are service operations), plus thetransitionbinding described below, while declared repository operations take neither (the wire artifact’s repository operations carry no capabilities, invocations, emissions, or transition bindings, so accepting one would silently drop data). A service operation’s clauses are fixed in the canonical ordercapability? invokes? emits? transition? when?— the order the formatter emits, so a source writingwhenbeforeemits(ortransitionanywhere but betweenemitsandwhen) is a syntax error, not a formatting opportunity. A service body may also declare decision dependencies withinject decision <qualified-name>;— the secondinjectform, alongside the plaininject <name>;dependency form (decisionis contextual: special only right afterinject,^decisionescapes;invokesis special only in the clause position,^invokesescapes). -
Transition binding (issue #176) — a delegated operation may carry
transition <Trigger>, the explicit declaration of the machine-bound transition the delegation fires (replacing the consumer’s per-state inference):service PayRunService {inject PayRunRepository;submit => PayRunRepository.save transition Submit;reverse => PayRunRepository.save transition PayRunStatus.Reverse;}The machine is the one bound to the delegation target’s repository design; the trigger must be one that machine declares — an explicit
vianame or a bare arrow’s derived name (the lowerCamel of the target state), never a name that is both (that binding is ambiguous and refused). The qualified spelling names the machine by its enum type name —PayRunStatus.Reverse, not a machine name, because Phero Lang machine declarations are field-named (machine status: PayRunStatus) so a machine-name qualifier does not exist as authored. A module carrying more than one machine-bearing design requires the qualifier (E0352); a binding on a declared operation, or on a delegation that is not a repositorysave, is refused (E0351); an unknown trigger is E0350 with the declared trigger vocabulary (did-you-mean included). The artifact carries the binding as authored —TransitionRef { trigger, machine }on the operation (issue #143’s lowering contract; see “The lowering contract” below). A binding that resolves also pins the machine’s class as the operation’s own-record condition scope — the multi-machine guard shape of issue #180. -
A service or repository operation may carry a leading
protectedmodifier (Sculptor’s visibility): the operation stays off the public interface.protectedis contextual — in leading position it is the modifier, anywhere else an ordinary name (^protectedescapes). -
Child-scoped operations (issue #179) — a module-level
extend <Class> { ... }block declares operations that act on the aggregate root’s contained children (the root-side counterpart of a child aggregate’s own lifecycle):extend PayRun {boolean addLine(PayRunLineDraft line) => lines.add;boolean removeLine(String lineId) => lines.remove;boolean saveLine(PayRunLineDraft line) => lines.save when (line.quantity > 0);}Each child operation is
protected? signature "=>" <feature>.<action> when? ;— thesignatureis the service-operation production (its return type is required, as for a declared service operation), and the dotted pair is exactly two names: the root class’s child feature and the action. The actions are the closed setadd,remove, andsave;add/removerequire a many-valued feature, whilesave(insert or replace) is legal for a single-valued child too. A class may carry several blocks — they merge in source order — and child-operation names must be unique per extend target.extendis a contextual word like the rest, and the block stays in the module holding the root’s design (the same-module coupling rule searches follow). -
Executable operation bodies (issue #178) — a service operation may replace its signature-only body with a closed procedural statement block, naming its primary repository after
=>:submit(PayRunLineDraft draft) capability SubmitPayRun => PayRunRepository {let run = self.findById("current") else error NotFound("PayRun", "current");guard run.total >= 0 else error Conflict("total");let units = run.lines.map(line => line.quantity).sum();run.total = units;let saved = self.save(run);let audit = PayRunAuditRepository.record("submit");}The optional leading return type makes the operation value-returning (absent is void); the
capability/invokes/emits/transition/whenclauses keep their canonical order before the=>. The=>name is one repository designed in the same module and injected by the service — it binds asself, and every other injected repository binds by its bare dependency name. A declared repository operation takes the same block after its optionalwhenguard (Type? name(params) when? { ... }) and binds its own design’s repository asself; a built-in repository operation takes no body (a named syntax error). The statement subset is closed —let,let … else error …, assignment to a local’s feature path,guard … else error …,if/else, andreturn— and every other statement is a parse error naming the construct. The raise vocabulary isNotFound(two arguments) andConflict(one argument); a value-returning body must return on every successful path. -
The repository built-ins are
findById,findAll,findByExample,findByKeys,save, anddelete— the consumer knows their signatures. A body’s receiver-call surface synthesizes onlyfindById(anOption<E>),findAll(aList<E>),save(E), anddelete(boolean);findByExample/findByKeysare outside the closed subset and are refused as unknown operations. -
Multiplicity annotations on return types and parameters (
String[],Book[0,3]) lower into the artifact’s cardinality slots (returnMultiplicity/ parametermultiplicity). -
A
searchprojection’s members may appear in any order (the formatter canonicalizes them); repeatedtext/filters/sort/document/capabilityclauses merge, and repeats of the single members (entity,ranking,analyzer,pagination) are idempotent — the first declaration wins. -
A document entry’s expression is captured raw (tokens up to the
;, nested brackets and string literals respected) and stored verbatim in the artifact; parsing and type-checking it is the driver’s job (rule 11). The expression language is the Tier-2 neutral expression language with the entity’s features asself— string concatenation via+is rule R9 ofdocs/EXPRESSIONS.md.
Resolution semantics
Section titled “Resolution semantics”Names resolve with the .mox cross-package rules against the union
namespace of the imported domains (the same rule multi-file .mox compiles
follow). A bare single-segment name must be unique across all domain
packages; when several packages declare it the error is
“ambiguous type X; qualify as p.X``` with the matching packages as help. A qualified pkg.Namemust match exactly one package. When the design declaresbase, that package becomes the preferred resolution scope: a bare name the base declares resolves there before the unique-across-packages lookup runs. base` itself must name an existing
domain package.
Resolution is validation-only: the artifact keeps every name exactly as authored (unqualified where written qualified and vice versa), and consumers resolve against the domain model.
Validation rules
Section titled “Validation rules”- base — an unknown base package is an error naming it.
- Uniqueness (application-wide unless noted) — module names; service names; design class references (one design per class per application, keyed by the resolved class); repository names; operation names within one service; operation names within one repository.
- Stereotype targets — every design’s
classmust resolve to a class in the domain model (entity, value, and dto designs all target classes). - Flag/stereotype compatibility —
scaffold,auditable, andoptimisticLockingare entity-only;nonPersistentis value/dto-only;cacheis legal on any design.auditableandoptimisticLockingdefault on for entity designs (Sculptor’s polarity): the artifact lowers them totrueunless the design opts out with the negated form (!auditable,!optimisticLocking). Negating a flag that defaults off (!scaffold,!nonPersistent,!cache) is rejected wherever written. - Repository placement — only entity designs may declare a repository.
- Aggregate boundary — see the derivation rule below; an entity design derived as a non-root may not declare a repository.
- Signature type-check — declared service and repository operation
signatures type-check against the domain model: the
phero-syntaxprimitives resolve everywhere, every other reference resolves through the bare/qualified rules above (any declared kind — class, enum, datatype, interface, vocabulary). A multiplicity annotation on a return type or parameter lowers into the artifact’s cardinality slots. - Delegations — the target must resolve to a service, a repository,
or a search declared anywhere in the application, and the operation must
name an operation on it (repository built-ins included; a search exposes
only the virtual operation
search). Service → repository and service → search delegation across different modules is the module-coupling error (“interaction between a Service in one Module and a Repository/Search in another Module is not allowed; go via a Service”); service → service across modules is allowed. - inject — every dependency name must resolve to a service, a repository, or a search of the application.
- Capabilities — only
compile_ddd_str_with_actorsvalidates: each declared capability name must exist in the union of the actor model’s blocks’ capabilities. Plain compiles record the names unvalidated — the design dimension is loosely coupled to the policy dimension by design. - Searches — a search’s name is unique application-wide. Its
entityline is required, resolves like a design target, and must bind to an entity design of the same module (the repository coupling rule; non-root entities may be searched — a projection over a contained entity is legitimate). Text fields must name a direct feature of the entity (inherited features included) that is text-like: the string primitives, enums, datatypes — the string-family default the constraint rules use — and vocabularies whose key facet is a string primitive; boosts are at least 1 and analyzers non-empty. Filters and sorts name direct features that are not class references. Document field names are unique per search, and every document expression is parsed and type-checked with the entity asself(any value type is legal; the consumer decides the rendered form). Pagination bounds satisfy1 ≤ limit ≤ max. - Value contract — a
valuedesign is an immutable value object (Sculptor’s ValueObject default, with no opt-out): every stored feature of the resolved class (own and inherited, derived features excluded) must carry the domain model’sreadonlymodifier. A changeable class designs asentityordtoinstead. - Aggregate reference constraint — an outside class may hold a cross
reference only to an aggregate’s root: for every cross-reference
(
refers) feature whose target sits under a containment chain (a non-root aggregate member), the referring class must share the target’s containment root. Computed over the whole union like the aggregate-derivation rule, and skipped when the verdict would be a guess (the target or source sits under two distinct roots, or under a containment cycle). - scaffold — a scaffolded entity design must declare a repository:
scaffoldstands for Sculptor’s generated CRUD operations (which also imply the service), and the artifact stays validation-only — nothing is synthesized — so the author declares the repository the scaffolding would hang off. The service remains the author’s to declare. - dto-with-ops warning — a
dtodesign whose class carriesopswarns once per op, naming the design and the op: a dto is a transfer record, and behavior on it is almost always a modeling mistake (see the layering heuristic under “Domain operations vs service operations”). Warning-severity: the artifact is unaffected, and the check runs only against a fully lowered union. - Typed status fields — an entity design whose class carries a
feature named
statusorstatetyped as a bareStringis an error: a status-like field is a lifecycle, and the bare-Stringshape is the silent no-machine case. Declare an enum in the domain model and amachine '<field>: <Enum>'on the design. Value objects and dtos are exempt (the check is entity semantics, the machine rule’s scope), and enum-typed fields stay legal — an enum without a machine is a legitimate modeling choice (the canonical fixture’sMediaStatus statusfield is the shape). - Decision links — only
compile_ddd_str_with_decisionsvalidates: every decision-service reference (an operation’sinvokesname, a service’sinject decisionname) must resolve by its last segment to a decision service declared in the union of the provided.dmnmodels’ services (the names pool across every provided model, the rule-10 shape), else the reference errorsE0250 unknown decision service '<name>' invoked by operation '<op>' of service '<service>'(injections:E0251 unknown decision service '<name>' injected by service '<service>') — spanned on the reference, with the did-you-mean closest declared service when one is a plausible typo, and the exhaustive list otherwise. Plain compiles (and any empty decisions bucket) record the names unvalidated — the design dimension is loosely coupled to the decision dimension by design. Everyimport "<path>.dmn"the design declares must be provided under that path when a non-empty decisions bucket is supplied, elseimported decision model "<path>" was not provided;.dmnimports are not domains and never lower as.moxsources — a plain compile (pherolang#154). - Version pins — an
invokesentry or aninject decisionline may declareversion compatibleorversion "X.Y.Z"(an exact semver). The pin narrows rule 17’s resolution against the models’ declaredversionmetadata: an exact pin resolves to the same-name service at that version (zero matches is E0252, naming the versions available; two or more candidates declaring the same version is E0253, ambiguous);compatibleresolves to the highest declared version in the highest major present (a model whose version is absent or not strictX.Y.Zcannot satisfy a pin — E0254 when none can). Unpinned references keep the rule-17 behavior; the compatibility contract itself is the decision model’s (same major = compatible — see the.dmnsurface’s Versioning section). ignores them outright. - Child operations (
extend) — the target class must resolve to a domain class with an entity design in the same module that is an aggregate root (a repository-bearing entity — rule 6’s root predicate); an unresolved class, a missing design, and a design without a repository are allE0358 unknown extend target. The child feature must exist on the target class (theextendschain included) and be a containment or cross reference (E0355); the action must be one ofadd,remove,save(E0357), withadd/removerequiring a many-valued feature (E0356). A child operation’swhenguard is typed with the operation’s declared parameters in scope and the child feature’s target class asself—line.quantityreads a parameter, a barequantityreads the child’s feature, and the root’s own features are out of scope. A resolved guard lowers verbatim asconditionand carries noconditionPaths(the resolved-path lowering belongs to service operations). The lowered operation rides the target design’schildOperationswith itschildScope. - Executable operation bodies — a body is typed against the
operation’s declared parameters and output (absent for a void
operation) and the repository receivers described above. The statement
subset is closed; every expression slot is phero-expr source checked under
the same typing rules as a
whenguard. The failure contract is hard: an unknown feature or operation, a type mismatch, a non-booleanguardorifcondition, an unknown raise variant or arity, and a value-returning path that does notreturnare allE0360(invalid service body in operation '<op>': <slot>: <detail>, repository bodies worded the same way). An unresolved=>receiver isE0359(unknown body receiver '<name>' on operation '<op>' of service '<s>') — the name must be a repository designed in the operation’s module that the service injects, written as a single (unqualified) repository name. The checked body lowers tobodies["stmt"]; nothing is attached when any diagnostic fired. Unlike the sigil-lowered Rune bodies’ generate-time deferral, the.dddcompiler never attaches an unchecked body.NotFoundandConflictare the reserved raise variants.
State machines
Section titled “State machines”An entity design may carry one lifecycle state machine — a
machine <field>: <Enum> block, written after the optional repository
block (the formatter’s canonical order; one per design). machine,
initial, terminal, and via are contextual words like every .ddd
word. The normative grammar (machine_decl, machine_member,
transition, guard_clause) lives in the parser module docs
(crates/phero-syntax/src/parser/mod.rs); the canonical fixture’s machine gives
the shape:
machine status: PayRunStatus { initial Draft; Draft -> Calculating via Calculate; Calculating -> AwaitingApproval via Submit; AwaitingApproval -> Approved via Approve when (total > 0); Approved -> Processing via Schedule; Processing -> Posted via Post; Draft -> Reversed; terminal Reversed; terminal Posted;}The driver validates the machine before it lowers (lower_design_machine
in crates/phero-driver/src/ddd.rs; behavior pinned by
crates/phero-driver/tests/ddd_machines.rs):
- Only an entity design carries a machine — a value object is immutable and a dto is a transfer record; neither moves through states.
- The machine’s type must resolve to an enum, and the bound feature
(
<field>) must exist on the designed class and carry exactly that enum type. - Closed over the enum — every state name (the
initial, aterminal, and every transition endpoint) must be a literal of that enum: a typo’d endpoint is an error naming the enum and its declared literals, never a new state. The enum’s literal list is the single vocabulary of the lifecycle. - Guards are predicates: a
whencondition on a transition — and on any service or repository operation — must type asboolean(the actor, deploy, and cmmn conditions’ rule). - Exactly one
initialstate. Duplicate terminal declarations and duplicate identical edges are rejected. - Determinism — one trigger name may leave a state for at most one target (the statechart event rule).
- Terminal states take no outgoing transitions; every non-terminal
state takes at least one (no dead ends — declare it
terminalor add an outgoing edge). - Reachability — every state is reachable from the initial state (breadth-first over the edges).
- Edge guards and operation
whenconditions type-check through the shared expression language (phero-expr) with the designed class as the implicitself— and guard literals against the machine field must be machine states: only the declared states bind as names, so a member of the bound enum that the machine never declares is a type error (it can never hold on this machine).
Condition binding. Machine states bind as enum-typed names — guards
read status == AwaitingApproval, never status == "AwaitingApproval".
The string literal is a type error under the same-type equality rule (R2 of
docs/EXPRESSIONS.md); the anti-magic-strings stance is deliberate. The enum-literal namespace (rule L3 of docs/EXPRESSIONS.md) is the general typed spelling beyond a machine’s own bound states: status == PayRunStatus.Draft names any declared member of the field’s enum as a typed value, and a string literal that names no declared member is the closed-set membership error rather than the generic mismatch. Edge
guards bind only the machine’s own states (a foreign machine’s states are
unbound — cross-entity state references are a type error). Comparisons
against other features are not machine-checked: their name operands
resolve as features.
Condition scoping (issue
#180) — a service
operation’s when guard is not single-entity. Three scoping regimes, by
the module’s machine count:
- One machine-bearing design (the original shape): that design’s class
is the guard’s implicit
self; bare feature names and machine states resolve against it. A guard with no injected path lowers noconditionPaths— the resolution is derivable from the artifact’s machine alone. - No machine-bearing design: an op-level guard has no own-record scope
at all and is refused (
does not have exactly one machine-bearing design), unless a resolvedtransitionbinding pins the scope (the joint shape with issue #176 above). - Several machine-bearing designs (multi-machine): there is no
implicit self — bare names must resolve uniquely:
- machine states bind when exactly one machine of the module declares
the name; a state two machines declare never binds, and referencing it
unqualified is the
E0354ambiguous-machine-scope error with thequalify as. “ help — the enum-literal spelling ( PayRunStatus.Draft) resolves it; - each machine’s bound field is addressable two ways: by its authored
field name when that name is unique across the module’s machines, and
always by the enum-derived spelling — the lowerCamel of the
machine’s enum type name (
machine status: PayRunStatus→payRunStatus). A field name two machines bind (statuson both designs) is theE0354error unqualified, with the spellings in the help; - a qualified path (
TimecardRepository.status) resolves against the service’s injected repositories — the first segment must match an injection name exactly as declared (case-sensitive; a near-miss is theE0353unknown-condition-path error with the did-you-mean), the repository must be designed in the same module, and the remaining segments are fields of the repository’s class (E0353names the class and suggests when a segment is no such field). A path that resolves but mistypes is ordinary typing: the same-type equality error, spanned on the condition.
- machine states bind when exactly one machine of the module declares
the name; a state two machines declare never binds, and referencing it
unqualified is the
Resolved condition paths. The artifact carries the guard’s resolution
as conditionPaths on the operation — ConditionPath { scope, field, fieldType } rows, scope adjacently tagged {"type":"ownRecord"} or
{"type":"injected","value":{"repository":…,"class":…}} (the repository
is the injection name as declared, the class the repository design’s
class; field is the dotted remainder, fieldType the field’s declared
type). A row lowers exactly when the path’s resolution is not derivable
from the artifact alone: injected paths always (the repository→class
binding is design knowledge the domain model does not carry), and
own-record paths in a multi-machine module (where the owning machine is
not a priori derivable). The condition source string stays verbatim;
consumers build on the resolved form (issue #143’s lowering contract)
instead of re-parsing guards.
Artifact shape. The machine lowers to machine on the design (a
phero_ir::ddd::StateMachine): the bound field, the enum TypeRef, the
initial state, the terminals, and the transitions — the via trigger
(None for a bare arrow) and the guard as the verbatim source text between
the parentheses (the when-condition rule). The state set is derived,
never serialized as a separate list: StateMachine::states() is the
initial state, then the terminals, then the remaining transition endpoints
in first-appearance order; terminalness is membership in terminals.
xstate-ir lowering. phero_driver::machines::lower_design_machines maps
every designed machine onto the
restingstate toolchain’s
language-neutral xstate_ir::Machine (rev-pinned as xstate-ir in the
workspace Cargo.toml; the sigil precedent). The mapping is total and
lossless for the v1 surface: id is the design class name; context is
one field (the bound feature, typed by the enum’s qualified name, default
the initial state); states follow StateMachine::states() with terminals
as final nodes and the rest atomic; transitions attach to their source
in artifact order, and the guard maps to a named guard whose name is the
condition’s source text. A bare arrow’s event is derived: the lowerCamel
of the target state (Posted → posted, AwaitingApproval →
awaitingApproval). The committed goldens pin both forms —
tests/conformance/ddd/payrun.ddd.json (the design artifact) and
tests/conformance/ddd/payrun.xstate.json (the lowered machine);
regenerate with PHERO_UPDATE_FIXTURES=1 cargo test -p phero-driver --test ddd_machine_golden.
Derived events. Every designed machine derives the state_changed event
contract (entity + field — phero_driver::machines::derived_events), which
is what makes the push plane’s state_changed vocabulary reachable for a
plain CRUD model once its lifecycle is modeled.
Aggregate derivation
Section titled “Aggregate derivation”The aggregate boundary derives from the domain model’s containment graph,
standing in for Sculptor’s belongsTo/!aggregateRoot markers: a class B
is contained when some class A owns a containment feature (contains)
whose type resolves to B, transitively. An entity design whose class is
contained by another stereotyped entity’s class is a non-root — it may not
declare a repository. The closure is computed only when every imported
domain lowered (a failed domain may hide containments, and a wrong
non-root verdict is worse than none).
Domain operations vs service operations
Section titled “Domain operations vs service operations”Mox class ops and .ddd service operations are deliberately different
layers, and the split is the layering heuristic:
- Behavior confined to one aggregate stays a class
op. It lives in the domain layer, is owned by the aggregate’s class, and generates as an instance method regardless of the class’s stereotype. - Behavior spanning aggregates, needing a transaction, or sitting at an
entry point is the cue for a
.dddservice operation. The service operation delegates down to the aggregate-owned ops; it never re-implements them. - Authorization attaches at the service-operation/capability boundary
(the
.actordimension). A domainopis not an entry point, so ops never takecapabilityclauses — guarding a domain method would bypass the service boundary the policy dimension is defined over.
A dto-designed class carrying ops is therefore flagged by validation
rule 15 (a warning per op): transfer records carry data between layers, and
behavior declared on them is almost always behavior that belongs on the
aggregate’s class or on a service. The escape hatch is unchanged: a
body-less op stays the abstract hook implemented by hand in target code
(the “Gap classes” divergence below).
Child-scoped operations (extend, rule 18) are the root-side slice of
the same heuristic: boolean addLine(PayRunLineDraft line) => lines.add;
declares, on the root’s design, an operation that reaches the aggregate’s
contained child through the root — no outside cross reference to a
non-root member (rule 13) is needed. The child’s own behavior stays a
domain op on the child class; the extend operation is the root-owned
entry point over it, and the aggregate boundary stays intact.
The lowering contract (issue #143)
Section titled “The lowering contract (issue #143)”Phero Lang aligns with codegraph#590:
public service operations lower to capability-gated routes downstream —
the generator emits one route per public operation, guarded by the
operation’s declared capabilities, with server-side when/never_both
enforcement. Phero Lang’s role is to make the surface the route is built
from declarative, which issues
#176 and
#180 complete:
- the declared
transition(TransitionRef) is the lowering contract for the machine-bound transition a delegatedsavefires — the consumer lowers the declared trigger verbatim (state patch = the trigger’s target state, conflicts via the machine guard) and pins it in its hurl/openapi contracts, instead of guessing from state names; - the resolved condition paths (
conditionPaths) are the lowering contract for the guard: injected-scope reads go through the injected repository on the handler’s already-loaded context, own-record reads through the bound entity — a closed subset the refusal of which names the construct; - the checked statement body (
bodies["stmt"], issue #178) is the lowering contract for the operation’s behavior: the consumer decodes theStmtBody, mapsself/bare-name receivers throughbodyReceiverand the service’s injections, and lowers the typed expressions with the shared R1–R9 semantics. No in-tree backend emits.dddbodies yet; the artifact is the contract, and the compiler never attaches an unchecked one.
Until an in-tree .ddd lowering exists, compiles therefore add no
“lowers to nothing” diagnostics: the artifact is the contract, and the
consumer owns the route emission. A protected operation stays legal but
off the public interface — a capability or transition carried only by
protected operations reads as dead to the coverage pass, whose
warning-severity lint (grantedCapabilityUncarried, the
pherolang#128
asymmetry) is the existing dead-trigger WARN; no new diagnostic bridges
the gap.
Capabilities and the actor dimension
Section titled “Capabilities and the actor dimension”A service operation’s capability clause names actor capabilities. Only
the library entry point compile_ddd_str_with_actors validates the names
against a caller-provided actor-model slice; capability names pool
across every model, so a host passes all of the application’s .actor
artifacts. An empty slice is the plain compile_ddd_str behavior (the
records-unvalidated loose-mapping contract). Since pherolang#128 the CLI
project pipeline and the LSP pass the sibling artifacts, so the check is a
compile-time language error — spanned on the capability clause, with the
unknownCapability code carrying the did-you-mean closest declared name
(a case-variant match wins outright; otherwise the nearest name within an
edit budget). Nothing in the design artifact constructs policy:
authorization remains the .actor dimension’s job (see the actor-policy
section of the language reference).
Both directions of the design↔actor wiring resolve at compile time
(pherolang#128): the CLI project pipeline and the LSP resolve every
capability clause against the pooled actor artifacts (existence, rule 10
above) and every grant against the pooled design artifacts (coverage —
compile_actors_str_with_designs, the actor engine’s coverage pass
diagnosing capability 'X' is granted but no operation or search carries it spanned on the grant entry, code grantedCapabilityUncarried). The
union integrity pass (phero_driver::check_integrity, register row
V12) keeps both findings as its lint channel (warning severity)
for hosts that gather artifacts without compiling — the compiler’s
diagnostics are the errors; pherolang check <dir> prints the lint only
for files phase 1 checked clean. Checking a design or policy alone stays
per-surface and silent.
Capability nouns on aggregate members (pherolang#144)
Section titled “Capability nouns on aggregate members (pherolang#144)”A capability is granted on a class (capability X on <Class>), and the
class’s place in the aggregate machinery decides whether that noun is a
sound grant. With designs provided, NounClassification
(crates/phero-driver/src/ddd/graph.rs) — a pure table over the union model
and the designs, exactly rule 6’s predicate — classifies every resolved
design class:
| verdict | class | a grant on it |
|---|---|---|
Value |
a value design — no aggregate to escape | clean |
Root |
an entity with a repository that no stereotyped entity contains | clean |
NonRoot |
an entity with a repository that another stereotyped entity’s class contains (transitively) | warning |
| none | no design, a dto, or a repository-less entity | silent |
The warning is capability '<X>' is granted on '<Class>', a non-root aggregate member (plus of '<Root>' and a “grant the capability on the
aggregate root instead” help when the containment chain tops out at exactly
one class), spanned on the class reference. Granting on a non-root member
hangs generation bindings (codegraph#596), so the warning points at the
root; an ambiguous chain names no root. compile_actor_file builds the
table only when designs are provided and every imported domain lowered
(union_model, the rule-6 discipline: a domain that failed to lower may
hide containment, and a wrong verdict is worse than none — its diagnostics
already block the artifact); an empty design slice, and the None table it
passes through lower_actors, is the exact plain compile_actors_str
behavior, byte for byte. The .ddd inline
actors block path always passes no table: actor lowering and the design
registries never co-occur there (domains lower through the shared
per-file union pipeline before designs exist, and
compile_ddd_str_with_actors receives pre-lowered ActorModels).
Events and the event-contract dimension
Section titled “Events and the event-contract dimension”A service operation’s emits clause names the events the operation
produces — the production-side counterpart of the .evt layer’s
contracts, channels, and subscriptions (a fragment, in the position the
grammar fixes for it — after the capability clause, before the when
guard):
service PayRunService { boolean approve() capability ApprovePayRun emits PayRunStateChanged when (status == AwaitingApproval);}Like capability names, emitted event names are loosely coupled: the
names lower into the artifact exactly as authored, and a plain
compile_ddd_str never validates them — the .ddd compile has no .evt
bucket. Both directions of the design↔event wiring are enforced by the
union integrity pass (phero_driver::check_integrity, register row
V12):
- Emission existence — every emitted event name on every service
operation must name an event declared in some provided
.evtcontract:operation 'approve' of service 'PayRunService' emits unknown event 'PayRunStateChanged', help listing the declared event names. - Emission coverage — every declared event must be emitted by at
least one service operation across the designs:
event 'E' is declared but no operation emits it. Channelpublishesentries and subscriptionevents [...]lists are consumption-side wiring — they route events to consumers and never satisfy production.
Checking a design alone (or a contract alone) stays per-surface and
silent: an emits clause in a cluster without .evt artifacts is not an
error, and a contract in a cluster without designs is not either — the
checks fire only when the counterpart surface is present in the checked
union (pherolang check <dir>).
Decisions and the decision dimension
Section titled “Decisions and the decision dimension”A service declares which decision services it relies on without knowing
how the decisions are computed — the .ddd side of the .dmn decision
surface (issue pherolang#78). Two forms, in the positions the grammar fixes:
service LoanService { inject decision underwriting.RiskScore; boolean approve() capability ApproveLoan invokes RiskScore, FraudScreen when (status == AwaitingApproval);}An operation’s invokes clause names the decision services the operation
invokes; inject decision <qualified-name>; names a decision service the
whole service injects. Like capability and emits names, decision
references are loosely coupled at the wire layer: they lower into the
artifact as their last segment exactly as the resolution universe spells
them, and a plain compile_ddd_str never validates them. Only the library
entry point compile_ddd_str_with_decisions validates the links (rule 17
above) against a caller-provided (path, DmnModel) bucket — the same
shape the .uc import buckets use. Decision services pool across every
provided model, so a host gathers the sibling .dmn artifacts the way it
pools actors. An empty bucket is the plain behavior.
The .dmn dimension stays the authority: it declares the decision
services (with their inputs, outputs, and encapsulated decisions); the
design only records the dependency edges. Imports of .dmn files are not
domains — the design’s import "<path>.dmn" declarations resolve the
decision bucket at compile time (rule 17’s provision check) and lower
nothing into the domain union.
CLI usage
Section titled “CLI usage”pherolang check library.ddd # validate design + imported domainspherolang ir library.ddd -o library.ddd.json # emit the DddModel artifactpherolang fmt --check library.ddd # canonical formatting gatepherolang artifact check library.ddd.json # validate the serialized artifact- Import paths resolve relative to the
.dddfile’s directory; each imported.moxis read from disk and compiled through the ordinary domain pipeline. A missing import file is a clean error naming the resolved path. An imported domain may itself declareimport schemaandimport sigil— the CLI provides that content too, so designs target lowered JSON-Schema and Rune types like any other class (see Importing Rune models). - Diagnostics come from every file involved and render grouped per file,
the way
.actorcompiles render them. pherolang iron a.dddfile emits only the design artifact; the domain IR is emitted by runningiron the.moxfiles themselves.pherolang fmtformats.dddfiles with the canonical layout rules: imports hoisted into a tight first section (each ending in;), then theapplicationblock;basefirst, design flags canonicalized toscaffold auditable optimisticLocking nonPersistent cache, search members canonicalized toentity text filters sort document ranking analyzer pagination capability(clause contents keep their source order; document expressions are re-emitted verbatim), and every operation/injectline ending in;. Anextend <Class>header expands like a service/design header, and each child operation is one canonical line —boolean addLine(PayRunLineDraft line) => lines.add;, with an optional leadingprotectedand the trailingwhen (...)in the service-operation order.- Directory batch mode (
pherolang check <dir>) picks.dddfiles up like every other surface: each design in the directory compiles independently, imports resolved relative to it.
Sculptor concept mapping
Section titled “Sculptor concept mapping”| Sculptor | Phero Lang .ddd |
|---|---|
| Entity | entity design |
| ValueObject | value design |
| BasicType | .mox datatype (referenced in signatures, never designed) |
| Repository | repository block on an entity design |
Service (+ injected => delegation) |
service with inject lines and name => target.operation operations |
scaffold / auditable / optimisticLocking / !aggregateRoot-style negations |
design flags (entity-only; auditable/optimisticLocking default on for entities, !flag opts out) |
belongsTo / !aggregateRoot |
derived from the domain model’s containment graph (see aggregate derivation) |
| Module | module label grouping services and designs |
| child-scoped aggregate operations | extend <Root> { ... } block (issue #179) |
| DTO | dto design |
findByQuery / findByCondition finders |
search projection (intent only: text fields with boosts, filters, sorts, ranking, pagination, document projections) — the backend chooses the engine (Postgres FTS, Tantivy, OpenSearch, …) |
generated GET /things/search?q= endpoints |
consumer concern — the artifact is backend-independent |
Deliberate divergences
Section titled “Deliberate divergences”Sculptor concepts Phero Lang intentionally does not carry, and why:
- Composite natural keys — expressible already: several
idfeatures on one class lower withisId, and the composite key is that set; the Tier-1 backends give the group its meaning (docs/LANGUAGE.md keepsid“carried; semantics land with Tier 1”). - Collection size constraints (
size,notEmpty) — expressible as the multiplicity annotation (String[1..10] tags), which the JSON Schema backend already lowers tominItems/maxItems; a constraint-block spelling would duplicate it. - Ordered collections (
orderby/orderColumn, Set/List/Bag) — persistence-engine metadata (JPA@OrderBy/@OrderColumn); Phero Lang features keep declaration order, and a backend that needs a different ordering derives it from its own configuration, not from the artifact. - Fetch / cascade — JPA persistence knobs with aggregate-derived defaults in Sculptor’s generator; the artifact is backend-independent, so persistence behavior belongs to the backend that owns the storage.
- Date validation (
past/future) and custom validators — runtime validation semantics; Phero Lang’s constraint system is deliberately schema-only (theuniqueprecedent), and there is no runtime validator hook to hang them on. - Gap classes — Sculptor’s generated-base/handwritten-subclass split is
a generation strategy; Phero Lang generates whole files, and a body-less
opstays an abstract hook implemented by hand in target code — the same separation without a second class. - Traits — a mixin composition feature of Sculptor’s Java generation;
no Phero Lang surface carries it yet. If a need lands, it belongs in the
.moxdomain model (interfaceextendscovers the signature-sharing half today). - Composite value types (BasicType) — Sculptor’s
BasicTypeis a multi-attribute embeddable (Money{currency, amount}); Phero Lang datatypes wrap a single platform type opaquely. Model the composite as a class designedvalue(rule 12 then requires its featuresreadonly); a true embeddable datatype awaits a deliberate.moxlanguage extension. - Multi-target arrows (
A -> B, C) — deliberately excluded from the machine surface: an unguarded external choice is not a deterministic machine; write one edge per transition. - Transition entry/exit actions and timers — v1 transitions carry only trigger, guard, and target; domains.toml approval chains and timers are downstream concerns.
.actorguards binding machine states — the actor dimension cannot yet reference a design’s machine states cross-file (a fast-follow); today only.ddd-internal conditions bind them.
Worked example
Section titled “Worked example”tests/conformance/ddd/library.ddd, the canonical fixture, designs a
library application over tests/conformance/ddd/library.mox. The fence
below is the fixture verbatim, its import repointed so it compiles from
this document (the docs fences are compiled by CI):
import "../tests/conformance/ddd/library.mox";
application Library { base nz.example.library module media { /// Coordinates lending of physical media across branches. service MediaService { inject PhysicalMediaRepository; inject MovieRepository; inject PersonService; inject MediaSearch; boolean borrow(String mediaId, Person borrower) capability BorrowMedia; PhysicalMedia registerMedia(MediaStatus status); protected save => PhysicalMediaRepository.save; registerBorrower => PersonService.register; findMedia => MediaSearch.search; } entity Library scaffold cache repository LibraryRepository { findById; findAll; save; delete; boolean renameLibrary(String name); } entity Book abstract entity Media entity Chapter auditable entity Movie repository MovieRepository { findById; findAll; save; delete; Movie findByTitle(String title); } entity PhysicalMedia scaffold repository PhysicalMediaRepository { findById; findAll; save; delete; PhysicalMedia findByStatus(MediaStatus status); } /// Full-text search over the movie catalogue. search MediaSearch { entity Movie text { title boost 3 synopsis analyzer "english" } filters { genre } sort { title } document { headline = title; blurb = title + " - " + synopsis; } ranking bm25 analyzer "english" pagination { limit 20 max 100 cursor } capability SearchMedia, SearchMediaAdmin } } module person { /// Keeps the member register current. service PersonService { inject PersonRepository; register => PersonRepository.save; findByName => PersonRepository.findByName; } entity Person repository PersonRepository { findById; findAll; findByExample; findByKeys; save; delete; protected Person findByName(String fullName); } search MemberDirectory { entity Person text { fullName boost 2 } filters { joinedOn } sort { fullName joinedOn } document { displayName = fullName; } ranking tfIdf pagination { limit 10 } } }}Notes on the example: Chapter declares no repository because it is
contained by Book (a non-root aggregate, rule 6); Book and Media
design the inheritance pair the domain declares; MediaService.save
delegates to a repository of its own module while registerBorrower
delegates across modules to PersonService (service → service, allowed).
Conformance
Section titled “Conformance”tests/conformance/ddd/ holds the canonical fixture set: library.mox
(the domain), library.ddd (the design, formatter-canonical), and
library.ddd.json (the committed artifact). The driver golden test
byte-compares the compiled artifact, the CLI fmt gate enforces the
canonical formatting, and pherolang artifact check classifies the artifact
as a DDD design. Regenerate goldens with PHERO_UPDATE_FIXTURES=1 cargo test --workspace.
The machine-bearing pair payrun.mox/payrun.ddd (formatter-canonical)
commits two goldens: payrun.ddd.json (the design artifact) and
payrun.xstate.json (the lowered xstate-ir machine), byte-compared by
crates/phero-driver/tests/ddd_machine_golden.rs; the machine validation
rules are pinned by crates/phero-driver/tests/ddd_machines.rs.
The multi-machine pair timesheet.mox/timesheet.ddd (formatter-canonical)
is the transition-binding and condition-scoping fixture (issues
pherolang#176/#180): two machine-bearing designs in one module, qualified
transition bindings, an injected-repository SoD guard, and the enum
-derived own-record spelling. Its committed golden timesheet.ddd.json
carries the transition and conditionPaths fields; the acceptance rules
are pinned by crates/phero-driver/tests/ddd_transitions.rs and
crates/phero-driver/tests/ddd_conditions.rs.
The child-operation pair childops.mox/childops.ddd (formatter-canonical)
is the pherolang#179 fixture: a PayRun aggregate root with a contained
PayRunLine[] lines collection and a single-valued PayRunNote child, a
dto PayRunLineDraft parameter type, and an extend PayRun block with
add/remove/save child operations (one guarded by a parameter field and
one by a child feature). Its committed golden childops.ddd.json carries
the design’s childOperations with each operation’s childScope; the
acceptance rules are pinned by crates/phero-driver/tests/ddd_child_ops.rs.
The executable-body pair bodies.mox/bodies.ddd (formatter-canonical)
is the pherolang#178 fixture: a void service body over the full statement
subset (let-else, guard, assignment, sum/size/any/all reductions,
self.findById/self.save, and a bare-name injected-repository call), a
value-returning if/else body with capability/when clauses, a
repository-operation body guarded by when, and an injected secondary
repository. Its committed golden bodies.ddd.json carries each operation’s
bodies "stmt" target and the service bodies’ bodyReceiver; the
acceptance and failure rules are pinned by
crates/phero-driver/tests/ddd_bodies.rs.
The examples/ side ships support.ddd (entity/value/dto designs,
searches, capabilities validated against support.actor through
compile_ddd_str_with_actors) and orders.ddd (the aggregate-derivation
rule over ecommerce.mox), byte-pinned against examples/artifacts/ by
crates/phero-driver/tests/examples_artifacts.rs. Note that
examples/library/library.mox itself is not designable: its Writer.books
cross reference escapes the Library aggregate (rule 13).