Domain services (domainService)
A domainService is a stateless, named, context-level container of
operations that belong to the domain layer but to no single aggregate.
It fills the gap between an aggregate operation (single-aggregate,
mutates this) and a workflow (application orchestration, owns the
transaction and all outbound I/O): a cross-aggregate domain
calculation or decision.
Operations are classified — by the phase-⑦ validator, derived from the
body (classifyDomainServiceTier, never a stamped field) — into a
three-tier capability ladder (mutating ⊇ reading ⊇ pure):
| Tier | The body may… | Callable from |
|---|---|---|
| pure | params only; branch, let, match, throw, call other pure services |
anywhere (aggregate ops, views, workflows, services) |
| reading | the above + read-only repository queries (Accounts.byHolder(h), Repo.find/findAll/run) |
application orchestrators only |
| mutating | the above + call mutating operations on the aggregates passed in (src.withdraw(amount)) |
application orchestrators only |
In every tier the application orchestrator (a workflow) loads the
target aggregates and owns the single commit; the service never writes to
a repository and never commits. Reading supporting data is the only
infrastructure a service touches, and only in the upper two tiers.
Why read-only but not read-write? A repository read dirties nothing, so it has no "who commits?" problem, and a repository-reading domain service is canonical DDD (uniqueness checks, policy lookups — Vernon). Loading-and-writing, by contrast, is application-layer work (Evans / Vernon / Cosmic-Python all agree) and can't stay stateless on the change-tracking backends — so it lives in the orchestrator. The design rationale + the five-backend idiom audit are in
proposals/domain-services.md.
Surface
module Sales {
domainService Pricing {
// pure — params only
operation quote(cart: Cart, customer: Customer): Money {
return cart.subtotal - customer.tier.discount(cart.subtotal)
}
}
domainService Registration {
// reading — a read-only repository query
operation isHolderFree(holder: string): bool {
return Accounts.byHolder(holder) == null
}
}
}
module Banking {
domainService Transfer {
// mutating — mutates the PASSED-IN aggregates via their own ops
operation run(src: Account, dst: Account, amount: Money)
: Transferred or InsufficientFunds {
if (src.balance.amount < amount.amount)
return InsufficientFunds { account: src.id, shortfall: amount }
src.withdraw(amount)
dst.deposit(amount)
return Transferred { src: src.id, dst: dst.id, amount: amount }
}
}
}
- Statement bodies; an
or-union return reuses the exception-less operation-return shape. - Cross-aggregate parameters are plain aggregate names (
cart: Cart) — a different grammar position from a containment partType, so theX idcross-aggregate restriction is untouched. - No
private/extern/audited/whenmodifiers (aggregate-op-only).
Calling one — the orchestrator load-protocol
A member call resolves to a Call with callKind: "domain-service"
(carrying serviceRef: { service, op }), so every backend renders a real
call without re-resolving. A pure service is callable from anywhere,
including an aggregate operation:
aggregate Order {
operation reprice(catalog: PriceList) {
let amount = Pricing.recalculate(this, catalog) // pure ⇒ legal here
this.total := amount
}
}
A reading/mutating service is application-orchestrator-only.
The protocol is uniform — orchestrator loads → service reads/mutates the
passed-in aggregates → orchestrator persists. The or-union result is
consumed by return-in-tail-position (the edge maps the error
variant → RFC-7807 ProblemDetails); persistence is the workflow's
auto-save-at-exit of the mutated args:
workflow MoveMoney transactional {
create(source: Account id, dest: Account id, amount: Money): Transferred or InsufficientFunds {
let src = Accounts.getById(source) // orchestrator LOADS
let dst = Accounts.getById(dest)
return Transfer.run(src, dst, amount) // service MUTATES src,dst; they auto-save at exit
}
}
(from/to are reserved words — params are source/dest.)
The contract (phase ⑦ IR validator)
| Forbidden in any tier | Diagnostic |
|---|---|
emit an event |
loom.domain-service-no-emit |
A this-rooted write (:= / += / -=) — there is no this |
loom.domain-service-no-mutation |
A repository write (save/insert/update/delete/add/remove/commit) |
loom.domain-service-no-repo-write |
| Start a workflow in the same context | loom.domain-service-no-workflow-start |
| Calling a reading/mutating service from an aggregate op / view body | loom.domain-service-infra-call-from-aggregate |
A resource-op (files.put(…), mail.send(…), …) — outbound I/O belongs to the orchestrator |
loom.resource-op-outside-workflow |
Reading a repository of another context — the reading tier is scoped to the service's own context |
loom.domain-service-cross-context-read |
Repository reads are allowed (they lower to a repo-read Call, not a
write) — but only of the service's own context: lowering resolves reads
against the enclosing context's repositories alone, so a cross-context name
never becomes a repo-read and every backend would render the unresolved
receiver verbatim (Customers.byName(r) with nothing defining Customers).
Move the service into the other context, or have the orchestrating workflow do
the read and pass the value in as a parameter. Note that "the orchestrating
workflow does the read" means through one of the two sanctioned crossings
below — a workflow is bound by the same boundary and may not name another
context's repository either (loom.workflow-cross-context-repository,
workflow.md);
the workflow's privilege is that the application layer is where the api call
and the projection read are available, not that it can reach into the other
context's model.
Cross-context data, in DDD terms (a decision, not a TODO)
The loom.domain-service-cross-context-read gate is permanent by design,
not an interim awaiting a "cross-context read port" (decided 2026-08-23). A
bounded context is a language boundary: another context's repository is its
internal model, and a domain service is an internal detail of the context that
declares it — never visible outside it, never seeing outside it. Loom already
models both sanctioned crossings, and both sit in the application layer
(workflows / handlers), which is where cross-context orchestration belongs:
- The other context's published API (Open Host Service, with
wireShapeas the published language): bind it withresource <name> { for: <Ctx>, kind: api, use: <Api> }and call it from a workflow — a typed in-systemremote-api-op, addressed via the<RESOURCE>_URLseam, on every backend. - The other context's published events, carried over a
channeland folded into a local model — the fold is the anti-corruption translation, and the domain then reads only its own model. Which local model depends on the reader: aprojectionis a read model served through its GET endpoint and pages (a workflow cannot load it — no repository;loom.workflow-unknown-name), while a workflow reads a local replica aggregate, fed by a correlatedcreate(e: <Event>) by …starter, through its own repository (let r = Replicas.find(ForKey(k))— seeworkflow.md).
Either way the callable surface of a context is its api (commands and queries
routed to aggregate operations and workflows) plus its published events; the
workflow fetches, and the domain service receives values as parameters.
Co-hosting two contexts on one deployable is a deployment fact and does not
loosen the model boundary — the model decides, deployment realizes. Mutation of a passed-in
aggregate via its own operation is
allowed in the mutating tier (it's a method-call, never the
no-mutation statement gate). Plus an anemic-domain warning
(loom.domain-service-single-aggregate) when every operation takes
exactly one aggregate parameter — the behaviour could be an operation
on that aggregate instead.
Per-backend emission
One neutral IR (repo-read / classifyDomainServiceTier /
domain-service-call), five idiomatic renderings. The pure tier is a
stateless static/module function on every backend (byte-identical to the
v1 floor); the reading/mutating tiers diverge by idiom:
| Backend | Pure | Reading (read handle) | Mutating (persistence) |
|---|---|---|---|
| TS / Hono | export namespace Pricing |
read-port parameter (accounts: AccountRepository) |
orchestrator repository.save(arg, tx) in db.transaction |
| .NET / EF | public static class |
DI'd service (sealed class + injected IAccountRepository, AsNoTracking) |
tracked entities flush at SaveChangesAsync |
| Java / Spring | static utility class | @Service bean (@Transactional(readOnly = true)) |
managed entities flush at @Transactional |
| Python / FastAPI | bare module functions | read-port parameter (accounts/session) |
session UoW commit() |
| Elixir / Phoenix | App.Domain.Services.Pricing module |
a context function (ambient Repo) |
inlines into the workflow's with-chain; each mutated arg routes to its context mutating fn (Repo.update), atomic via Repo.transaction |
All three tiers ship on all five backends
(src/generator/{typescript,dotnet,java,python,elixir}/…/domain-service*,
the shared src/ir/util/domain-service-{tier,read-ports}.ts, and the
computeSaves mutated-arg derivation in src/ir/lower/lower-members.ts).
Companion: function block body
A function may now take a pure block body (statements) instead of
only a single = Expression:
function lineTotal(l: Line): Money = l.qty * l.price // expression form (SQL-inlinable)
function shippingFor(cart: Cart, dest: Region): Money { // block form
let weight = cart.lines.sum(l => l.item.weight)
if (dest.isDomestic) return weight * Rates.domestic
return weight * Rates.international + cart.customsSurcharge
}
A block-body function stays pure — params only, no repository
(not even a read), no mutation/emit (loom.function-block-impure);
throw/require for the bug regime are allowed. The expression form
stays SQL-inlinable (queryable like a criterion); the block form
is not queryable (a block-function call is rejected in a where /
criterion / view-filter position, the same as any call). This keeps the
ladder crisp: function (pure; expression form inlinable) → criterion
(pure, queryable predicate) → domainService (reads, decides, mutates
passed-in aggregates).
For how a domainService operation compares to an aggregate operation, a workflow and a handler, see Choosing a callable kind.