Skip to main content

Validation

workspace.validate() checks the structural rules that DDD lets us verify without knowing the business, and returns a list of diagnostics with a severity, a rule id, a message and the ref of the element concerned.

RuleSeverityChecks
ods-versionerrorthe file states the ODS version it was written against and its major is this core's; the major is bumped by the decision that breaks the metamodel, so a file whose major differs — or that states none — was written against a model this reader does not read the same way, and used to fail as unresolved refs and rule errors that named the symptom. The file still loads and every other rule still runs
unresolved-referrorevery $ref a loaded file writes names something, and something the field it sits in can hold. The link is left unset and reported here rather than throwing, so one typo costs the author that diagnostic and not the whole file's
unknown-fieldwarningevery field a loaded file writes is one this metamodel knows; the model has no such element, see what it has instead. The field is dropped and reported rather than kept silently, and it stays dropped on the round trip
aggregate-rooterror / warningexactly one root entity per aggregate of a context whose insides are knowable; a big ball of mud is exempt, since nobody can read it well enough to say what leads its clusters
cross-aggregate-referenceerrorrelations into another aggregate are references to its root, or a kind of that root; a relation to a value object crosses no aggregate, since the context declares it
cross-context-relationerrora relation never crosses a bounded context, except a uses relation to a value object the borrowing already allows (a shared kernel, or a conformist downstream of the value's context); for an entity the source holds its identity instead
identifies-entityerroran attribute's identifies names an entity of this workspace, root or child, a bounded context marked external, bigBallOfMud or boundaryOnly, or a schema an external or boundaryOnly context publishes; a child is reached through its own root, so the dependency is on the aggregate that root leads; a whole context is named when the id belongs to a system whose entities are not ours to state, not anyone's to find, or not yet written down, and one of that context's published schemas when it documents the kind the id names — a processor's Customer beside its Payment — which still reads as an identity into that context
root-identityerrorthe root entity of an aggregate in a context whose insides are knowable declares at least one identity attribute; a big ball of mud is exempt, as it is from aggregate-root
entity-identitywarningevery other entity in an aggregate declares at least one identity attribute; without one it is a value object. A big ball of mud is exempt, as it is from aggregate-root and root-identity
value-object-shapeerrora value object declares no identity attribute, and its relations uses other value objects: it includes nothing, references nothing and reaches no entity, because a value is a value of something and nothing is reached through it
identity-not-optionalerroran identity attribute is not marked optional; an identity that may be missing cannot say which instance a reference means
specialisation-in-boundaryerroran entity is a kind of an entity of its own aggregate; a value object is a kind of one its own context declares, or one it borrows on any of the three routes borrowing runs on — through a shared kernel, as a conformist, or as the customer of a customer-supplier relationship with the context that owns it
specialisation-cycleerrorno chain of "is a kind of" returns to where it started
specialisation-not-rooterroran entity that is a kind of another is not itself marked root
specialisation-redeclareserrora kind does not declare an attribute it already has from what it is a kind of
aggregate-treeerror / warninginside an aggregate includes and references point at entities and uses at value objects, and every entity is reachable from the root. The tree is a claim about instances and the model declares types, so no ring among types is reported at all: a questionnaire whose groups hold questions that hold groups is still a finite tree per instance
attribute-relation-coherencewarninga uses relation declared for an attribute typed by a value object agrees with it about how many there are, and where one value object is used twice each relation names in for the attribute it draws
relation-for-resolveserrora relation's for names an attribute of the entity or value object that declares the relation; an attribute inherited from what it is a kind of counts as its own
attribute-one-shapeerroran attribute is typed by a value object or by a schema, never by both, and only a schema's attribute names a schema: a payload shape belongs at the boundary, so an entity or value object names a value object
invariant-in-value-objecterrorevery element a value object's invariant constrains is that value object, one of its own or inherited attributes, or a value it composes and that value's attributes in turn: an Itinerary whose legs are typed by a Leg may say that each leg's arrival precedes the next leg's departure, because a value is made whole out of the values it holds; nothing off that composition path is in reach
invariant-in-aggregateerroran aggregate's invariant holds inside the boundary on every save, so every element it constrains belongs to that aggregate — an entity, an attribute or one of its operations — or is a value object something in the aggregate holds, its context's own or one borrowed from elsewhere, or is an operation of a service of its own context, application or domain, that guards it. Naming an operation says which operation keeps the rule, not what kind of rule it is; precondition and postcondition say that. A precondition may also constrain attributes of the schema the operation it guards takes, of what a call that guard — or the front that calls it — already made comes back with, and of the payload of an event the policy or process issuing that guard heard; a postcondition those of the request, the answer and the refusals; both follow composition into the shapes those compose. Another context's own entities stay out of reach either way
invariant-in-contexterrorevery element a context's invariant constrains belongs to that context: an entity or attribute of any of its aggregates, a value object something in the context holds, its own or a borrowed one, or one of its operations. A precondition may also constrain attributes of the schema the operation it guards takes, of what a call that guard — or the front that calls it — already made comes back with, and of the payload of an event the policy or process issuing that guard heard; a postcondition those of the request, the answer and the refusals; both follow composition into the shapes those compose. Another context's own entities stay out of reach either way
context-invariant-is-checkederrora context's invariant is a check, so it names at least one operation of that context that makes it — before that operation acts, or of what it answers with
precondition-names-operationerroran invariant marked a precondition names at least one operation it guards: a rule checked before nothing in particular is a rule checked nowhere
postcondition-names-operationerroran invariant marked a postcondition names at least one operation it guards, and is not also marked a precondition: a guarantee about an answer needs a call to be the answer of, and a rule that is both says two things about when it holds
relationship-roles-backedwarninga directed relationship's declared roles are carried by the crossings that belong to it, and a crossing consumption's role is declared on the agreement it belongs to; a crossing consumable's shape — sent, answered or refused — backs a published-language role, the downstream borrowing the upstream's shapes backs published-language and conformist, and an operation the upstream calls in its own shapes backs anti-corruption-layer. Where one pair holds two agreements in the same direction, each is read against its own traffic
consumption-agreementwarninga consumption crossing a pair that holds more than one directed relationship in that direction names in relationship which agreement the exchange belongs to, and what it names is an agreement joining those two contexts, either way round — upstream is whoever dictates the model, so an exchange whose caller is the upstream runs under an agreement pointing against the traffic. A pair with one agreement is asked nothing; a crossing reported here belongs to no agreement, so relationship-roles-backed says nothing about it until it names one
relationship-declaredwarningtwo contexts joined by a crossing — a consumption of the other's consumable, a policy or process reacting to the other's event, or an attribute typed by the other's value object — declare a relationship, in either direction
relationship-duplicateerrora pair of contexts declares at most one unnamed directed relationship per direction and at most one unnamed relationship of each symmetric type; customer-supplier is a flavour of upstream/downstream rather than a second joint, so two of them the same way round disagree about how the pair stands. Two agreements between one pair in one direction — a negotiated API and a tolerated legacy feed — each carry a name of their own, and both stand
relationship-cyclewarningthe directed relationships whose traffic is calls form no cycle; calls carried only by events, calls whose consumption declares an anti-corruption layer, and calls between partners do not count
partnership-backedwarningtwo contexts declaring a partnership exchange consumables, or events a policy reacts to, in at least one direction
shared-kernel-backedwarningtwo contexts declaring a shared kernel share a value object, a schema, or an operation one of them calls on the other, across it
conformist-backedwarninga downstream that declares the conformist role takes something of its upstream's: a schema or value object named here, something it publishes consumed here, or one of its operations called
mud-needs-aclwarninga consumption from a big ball of mud declares the anti-corruption-layer downstream role; read from consumptions only, so an identity attribute that merely names the mud is not itself traffic and needs none
term-in-contexterrora glossary term's embodiedBy names an element of the term's own context
role-coherencewarningwhere the provider of a crossing consumable is the upstream side, the consumable declares an upstream role and the consumption a downstream one. Which end is upstream is the relationship's to say: where the caller dictates the format and the provider translates it, neither field is the right place for either role and the relationship carries them. Partners and contexts sharing a kernel have no sides at all, and a customer-supplier downstream is asked for no role — it negotiates the interface it uses, which neither downstream word describes, and the relationship type says it; a role written there is still read. Nor is a consumer marked external or boundaryOnly asked for one: whether it conforms or translates is the inside of a system the model says is not ours to state, or has not yet been read. The upstream role on our own consumable is still asked for. Where the pair holds two agreements, the direction is read off the one this exchange names
separate-wayserrorcontexts that declare separate ways exchange no consumables, react to none of each other's events, hold none of each other's identities and borrow none of each other's value objects
internal-consumableerror / warningan internal consumable is not consumed, reacted to or issued from another context
consumption-onceerrora consumer consumes a given consumable once, or several times with each of those consumptions naming callers in by that no other of them names; the ref of a repeated pair is the pair plus its first caller, so unnamed or shared callers leave two consumptions with one ref and only the first can be reached
consumption-by-resolveserrora consumption's by names the consumer's own operations, or the policies and processes of the consumer's context; a consumption belongs to the consumer, so what makes it is the consumer's own
consumption-by-operationerrora consumption of an operation names operations in its by; a policy or a process may be named only on a consumption of an event, because a reactor issues a local operation and that operation makes the call
consumption-by-reactorerrora consumption of an event names policies and processes in its by; an operation is issued rather than woken, so what takes a fact in is the reaction of the consumer's context that wakes on it
consumption-by-requiredwarninga consumption of an operation names in by which of the consumer's own operations makes the call, unless the consumer provides exactly one and there is nothing to choose between; by is the only causal link the model has from one operation to the next, inside a context as much as across a boundary. A consumer that provides no operation is reported too: there is nothing to name, nothing to infer, and a policy may not be a by on an operation consumption, so nothing in that context can make the call and the fix is the missing operation rather than the missing by. A consumer inside an external context, a big ball of mud or a context modelled at its boundary only is not asked, because naming a caller in a machine nobody here owns, can read, or has yet interviewed is invention
subscription-consumederrora policy or process whose on, starts or ends names another context's event has a consumption of that event somewhere in its own context; a subscription is an integration and belongs on both maps
subscription-backedwarninga consumed event is reacted to by a policy or a process of the consumer's context. A consumer inside an external context, a big ball of mud or a context modelled at its boundary only is not asked, because how a machine nobody here owns, can read, or has yet interviewed reacts is not ours to state
process-in-contexterrora process issues operations of its own bounded context, and starts on an event or on one of its own context's operations; what it waits for and what ends it may be another context's events
process-has-endswarninga process names at least one event that completes an instance
process-startserrora process names at least one event, or one operation of its own context, that begins an instance; an answer and a deadline start nothing, because both need the instance to exist already
policy-in-contexterrora policy issues operations of its own context; it may still react to another context's event
aggregate-not-publicerroran aggregate's operations declare no upstream role and are consumed only inside their own context, or by a context that shares a kernel with it
aggregate-consumes-insideerroran aggregate consumes only consumables of its own bounded context, and never another aggregate's operation; a foreign operation or event is consumed by an application service or a policy, and a call to the aggregate next door is fronted by a service
domain-service-internalerrora domain service's operations declare no upstream role and are consumed only inside their own context. Not asked of an external context: a service's type says where a piece of our own model sits, and a system we do not own has no inside for that distinction to be about
domain-service-consumes-insideerrora domain service consumes only consumables of its own bounded context; the application service makes the call and hands it what it needs
valueobject-contexterroran attribute types itself by a value object of its own bounded context, of one it shares a kernel with, of an upstream it has declared itself a conformist of, or of a supplier it is the customer of under a customer-supplier relationship — that pair negotiated the interface between them, so the supplier's published types are a language the customer had a say in settling, and it declares no downstream role. A partnership is not a fourth route: partners plan and release together, which is not keeping one model between them, so a partner pair that shares a shape declares a shared kernel beside the partnership
schema-contexterrora schema named by a consumable's payload, by its returns, by one of its rejections or by a nested attribute belongs to the naming element's own context, to one it shares a kernel with, to an upstream it has declared itself a conformist of, or to a supplier it is the customer of; a consumable an upstream caller consumes may carry that caller's shape, which is what an anti-corruption-layer translates. A partner pair that shares a shape declares a shared kernel beside the partnership
returns-on-operationerroronly an operation declares returns; an event has no caller to answer
rejects-on-operationerroronly an operation declares rejects; an event is a fact that already happened, so it has nothing left to refuse
consumable-kinderrorpolicies and processes react to events, and to the answers of calls they made — an operation the reactor issues, one an operation it issues names in by, or one an operation it issues reaches along this context's by chain through any number of local fronts, a silent consumption whose consumer provides a single operation counting as a hop of that chain because that operation is the by the model was not made to write. For a process, the operation named in starts counts as one it issues, because the operation that starts a process is that process's own first step and the calls it makes are the process's calls; they issue operations, and only operations raise, and only events. An answer is the shape the call comes back with, the shape it refuses with, one of the outcomes that refusal enumerates, or the bare completion of a call that answers with nothing, and the operation has to declare the one that is named
raises-in-contexterroran operation raises only events its own bounded context provides; a context publishes its own facts
raises-in-aggregateerroran aggregate's operation, and a domain service's, raises only its own provider's events; each aggregate is saved in its own transaction, so one node making another's fact true spans two of them with no consumption and no edge on any map to say so — the act aggregate-consumes-inside refuses when it is written as a call. A domain service is asked the same question because it is the inside of the model as much as an aggregate is. An application service's operation is the one exemption and may raise any aggregate's event of its context: the context's use case runs both transitions
raises-restatedwarningan operation does not restate under raises an event an operation it calls through a consumption's by already raises; the chain carries it, and a copy can drift
rejection-raisedwarningan operation does not raise, as an event no policy or process reacts to, a shape it also rejects with; a rejection answers the caller and an event tells the world, so where both are true the model keeps both, and the fact somebody hears is what makes it an event. A reactor of the raising context counts: the dunning policy that hears a decline is not the caller
event-unraisedwarningevery event of a context we model is raised by one of that context's own operations. Three kinds of context are exempt, for one reason worded three ways: an external system's insides are not ours to state, a big ball of mud's cannot be read at all, and a context modelled at its boundary only has not been read yet
policy-completewarninga policy reacts to at least one event and issues at least one operation
reaction-cyclewarningthe reactions form no cycle: no operation raises an event whose policy issues an operation that leads back to it, following a consumption's by across a context boundary; a process fed by its own steps is a lifecycle rather than a ring, and so is a ring where every other reactor is a policy that only translates an event in through an anti-corruption-layer consumption and republishes it as its own context's fact, so a cycle is reported only when the walk returns to a reactor other than that process, such a translating policy, or a process the ring merely calls — one that enters on its own starts and leaves on its own ends, which is a call at process granularity — and the policy has to be translating on that ring, woken there by its anti-corruption subscription and leaving there by an operation that raises the event carrying the ring on. The exemption asks that what comes back to the process continues an instance: where what it hears on the ring is one of its starts, every turn begins another instance, and the ring is reported as a cycle that spawns instances — which is what a process that starts on and issues one operation does, whether or not a translating policy stands on the ring. A ring with no policy or process on it at all is worded as calls rather than reactions, and reported once: relationship-cycle already covers the same ring where it crosses contexts, so this rule stays quiet there and speaks only where the ring stays inside one context
context-serves-subdomainwarningevery bounded context serves at least one subdomain, except an external context, which was never in the problem-space view, and a shared kernel context, which is under all of its sharers' subdomains rather than serving one of its own
external-is-boundaryerroran external context declares no aggregates, no policies, no processes and no internal operations or events, and is not a big ball of mud as well: one is somebody else's system, the other the enterprise's own. Its value objects may carry invariants, and it may state a context invariant marked precondition or postcondition on one of its own operations, or marked postcondition on one of its own events, because a published contract is citable — a webhook payload as much as an operation's answer. Such an invariant must name one of the context's own operations, or for a postcondition one of its own events, and may constrain only the attributes of the shapes that operation carries or of that event's payload, and the context's own value objects; an invariant with neither flag, one that names no operation or event of its own, or one that guards or constrains anything of another context, is refused
boundary-only-is-boundaryerrora context marked boundaryOnly — one of ours, coherent as far as anyone knows, that nobody has interviewed yet — declares no aggregates, no policies, no processes and no context invariants, and is not external or a big ball of mud as well: the three flags are three different answers to who may change the system and what can be known of it. It states the consumables it offers and takes, the schemas they carry, its value objects and its glossary, and it serves subdomains and has a team like any context of ours; an identifies may name it or one of its schemas, no rule asks how it reacts or which of its operations calls out, and nothing consuming it is asked for an anti-corruption layer, because it is not a mess. The flag comes off the day the interview happens
comments-requiredwarningevery context relationship carries a comment; opt in with options.rules.commentsRequired
disposition-needs-commentwarningan intent whose disposition is tolerated or refactor carries at least one comment

The UI reports the counts when a workspace is loaded and lists the diagnostics on the home page; the generated docs include them on the workspace page.

import { Workspace } from "@open-domain-specification/core";
import { describe, expect, it } from "vitest";

const ws = new Workspace("Shop", {
description: "Validation example",
version: "0.1.0",
});
const ordering = ws.addBoundedContext("Ordering", { description: "" });
const order = ordering.addAggregate("Order", { description: "" });
const orderRoot = order.addRootEntity("Order", { description: "" });

const catalog = ws.addBoundedContext("Catalog", { description: "" });
const product = catalog.addAggregate("Product", { description: "" });
product.addRootEntity("Product", { description: "" });
const price = product.addEntity("Price", { description: "" });

// Reaching into another aggregate's non-root entity breaks the aggregate rule,
// and Price is in another bounded context too, which no relation may cross: an
// order holds the product's identity and consumes what Catalog publishes.
orderRoot.references(price, "priced at");

// The identity an attribute holds may name a child: an order can hold the id
// of the Price inside the Product boundary, which it reaches through Product.
// That is a dependency the model records, not an error.
orderRoot.addAttribute("priceId", { type: "string", identifies: price });

// An internal consumable is not offered to other contexts, and a policy
// reacts to events, not operations.
const reprice = product.provides("Reprice", {
description: "",
type: "operation",
internal: true,
});
ordering
.addPolicy("Reprice on order", { description: "" })
.on(reprice)
.issues(reprice);

describe("Validation", () => {
it("reports structural DDD rule violations", () => {
expect(
ws.validate().map((d) => `${d.severity} ${d.rule}: ${d.message}`),
).toMatchInlineSnapshot(`
[
"error cross-aggregate-reference: "Order" references "Price", which is neither the root of aggregate "Product" nor a kind of that root; reference "Product" by its root's identity, holding "Price"'s id beside it when the child is what you mean",
"error cross-context-relation: "Order" in "Ordering" references "Price" in "Catalog"; a relation never crosses a bounded context, so hold "Price"'s identity in an attribute of "Order" with \`identifies\`; where what you need is a value rather than an entity, borrow it through a shared kernel, as a conformist, or as a customer of a supplier",
"error root-identity: Root entity "Order" of aggregate "Order" declares no identity attribute, so nothing says which "Order" a reference means",
"error root-identity: Root entity "Product" of aggregate "Product" declares no identity attribute, so nothing says which "Product" a reference means",
"warning entity-identity: Entity "Price" in aggregate "Product" declares no identity attribute; an entity is what you tell apart from another holding the same values, so without one "Price" is a value object",
"warning aggregate-tree: "Price" is in aggregate "Product" but no chain of "includes" or "references" reaches it from "Product", so nothing inside the boundary can get to it",
"warning relationship-declared: Policy "Reprice on order" in "Ordering" reacts to "Reprice" from "Catalog", but no relationship says how "Catalog" and "Ordering" stand to each other",
"error internal-consumable: Policy "Reprice on order" reacts to "Reprice", which is internal to "Catalog"",
"error internal-consumable: Policy "Reprice on order" issues "Reprice", which is internal to "Catalog"",
"error subscription-consumed: Policy "Reprice on order" reacts to "Reprice" from "Catalog", but nothing in "Ordering" consumes it; a context takes a foreign fact in at its own boundary, so the subscription is a consumption and reads as one on both maps",
"error policy-in-context: Policy "Reprice on order" in "Ordering" issues "Reprice", which belongs to "Catalog"",
"error consumable-kind: Policy "Reprice on order" reacts to "Reprice", which is an operation, not an event",
"warning reaction-cycle: Reactions run in a cycle: "Reprice" -> "Reprice on order" -> "Reprice"; the chain triggers itself and nothing in the model says what ends it; it runs through "Catalog" and "Ordering", so no one context can see the whole ring",
"warning context-serves-subdomain: Bounded context "Ordering" serves no subdomain, so it is missing from the problem-space view",
"warning context-serves-subdomain: Bounded context "Catalog" serves no subdomain, so it is missing from the problem-space view",
]
`);
});
});