Linting Domain-Driven Design docs
Enforce the strategic-DDD shape – a Bounded Context’s language and boundaries, and the relationships between contexts – across your repository’s DDD docs.
What ctxgrd checks – and what it does not
ctxgrd checks two document shapes: a Bounded Context doc carries the required headings and metadata, and a Context Map doc names exactly two contexts with a relationship pattern that declares direction correctly. It does not check whether your model is actually right – whether the boundary you drew matches your code, or whether the Ubiquitous Language you wrote down is the language your team actually speaks. That is a modeling judgment, not a structural rule.
The pack also does not model tactical DDD (Value Objects, Entities, Repositories). Those are code-shaped – a markdown twin of a Repository drifts from the implementation the moment either one changes – so they stay in code and tests, not docs.
Prerequisites
ctxgrd0.51.0 or later.- A
ctxgrd.tomlin the repository root. If none exists, runctxgrd pack add project-docsto create a baseline.
1. Add the pack
ctxgrd pack add dddThis writes two blocks into ctxgrd.toml: [BOUNDEDCONTEXT], whose canonical
home is docs/ddd/bounded-contexts/**, and [CONTEXTMAP], whose canonical
home is docs/ddd/context-maps/**. To preview what would be written without
changing anything:
ctxgrd pack add ddd --dry-runUnlike the guide or c4 packs, both DDD namespaces are id-claimed: a
Bounded Context doc carries id: BOUNDEDCONTEXT-<n> and a Context Map doc
carries id: CONTEXTMAP-<n>. DDD docs reference each other constantly – a
Context Map names two contexts – so they ride the same depends_on graph
your ADRs and PRDs already use, instead of a rule reading a sibling file off
disk.
Run the linter right after adding the pack and you will see one diagnostic, not zero:
error[core.min-docs]: namespace `BOUNDEDCONTEXT` requires at least one document but the run found noneThat is the pack telling you what to do next, not a misconfiguration –
BOUNDEDCONTEXT requires at least one document to exist. Write your first
context (step 2) and it clears. CONTEXTMAP carries no such requirement –
it stays quiet until you write a relationship, because a map with zero edges
is a normal, valid state for a project still working out its first context.
2. Write a Bounded Context
Create a file under docs/ddd/bounded-contexts/. Every Bounded Context
requires five frontmatter fields (id, title, status, owner,
subdomain_type) and eight headings, in order: Purpose, Ubiquitous Language,
Aggregates, Domain Events, Boundaries, Team / Ownership, Open Questions,
References.
---
id: BOUNDEDCONTEXT-1
title: Ordering
status: active
owner: Ordering Team
subdomain_type: core
---
# Ordering
## Purpose
Owns the lifecycle of a customer's order, from cart to fulfillment handoff.
## Ubiquitous Language
- **Order** -- a confirmed set of line items a customer has committed to buy.
- **Cart** -- the mutable, pre-checkout collection of line items.
- **Fulfillment** -- the act of picking, packing, and shipping an order.
## Aggregates
- **Order** -- root; enforces that line items cannot change after checkout.
## Domain Events
- **OrderPlaced** -- emitted when a cart converts to an order.
- **OrderFulfilled** -- emitted when fulfillment confirms shipment.
## Boundaries
Owns cart and order state. Does not own payment capture or invoicing -- those
belong to Billing.
## Team / Ownership
Ordering Team.
## Open Questions
- Should partial fulfillment split one order into two?
## References
- None yet.Aggregates and Domain Events are headings inside this one file, not separate documents – the pack folds tactical detail into the context that owns it rather than spreading it across a directory that drifts out of sync.
status must be one of draft, active, deprecated – a context lives and
dies, it is not accepted once and left alone. subdomain_type must be one of
core, supporting, generic, Evans’ subdomain classification. Both are
config-overridable (see step 4).
Write a second context so the next step has two real endpoints to relate:
---
id: BOUNDEDCONTEXT-2
title: Billing
status: active
owner: Billing Team
subdomain_type: supporting
---
# Billing
## Purpose
Owns payment capture and invoicing for placed orders.
## Ubiquitous Language
- **Invoice** -- a billable record derived from an order.
- **Charge** -- a single payment capture attempt against an invoice.
## Aggregates
- **Invoice** -- root; enforces that a charge cannot exceed the invoice total.
## Domain Events
- **InvoiceCharged** -- emitted when a charge succeeds.
## Boundaries
Owns payment and invoicing state. Reads order data from Ordering; does not
own cart or fulfillment.
## Team / Ownership
Billing Team.
## Open Questions
- None yet.
## References
- None yet.Run the linter:
ctxgrdWith both contexts in place and no Context Map yet, this is already clean – Context Maps are optional and incremental. Write one only once you have two contexts worth relating.
3. Note the id, not the abbreviation
A Bounded Context’s id is BOUNDEDCONTEXT-<n>, not BC-<n>. In ctxgrd, a
document’s namespace comes from its id prefix, so the id prefix has to match
the section name that claims it – [BOUNDEDCONTEXT] claims
BOUNDEDCONTEXT-<n> ids, not BC-<n>. There is no shorthand out of the box.
If your team wants shorter ids, you can rename the section to [BC] in your
own ctxgrd.toml and use BC-<n> from then on – but the pack ships with the
descriptive long form, and that is what pack add ddd writes.
4. Map a relationship
A Context Map is one file per relationship edge – not a single document
listing every relationship. Create a file under docs/ddd/context-maps/
naming exactly two contexts in depends_on, plus a pattern from Evans'
eight strategic patterns.
---
id: CONTEXTMAP-1
depends_on: [BOUNDEDCONTEXT-1, BOUNDEDCONTEXT-2]
pattern: Customer-Supplier
upstream: BOUNDEDCONTEXT-1
downstream: BOUNDEDCONTEXT-2
---
# Ordering / Billing -- Customer-Supplier
Billing reads order data to build invoices, so Ordering is upstream: it ships
changes on its own schedule and Billing adapts downstream.Customer-Supplier is asymmetric – one side leads, the other follows – so it
requires upstream and downstream fields naming which context plays which
role. Symmetric patterns (Partnership, Shared Kernel, Separate Ways)
forbid those fields instead, since neither side leads.
Run the linter to confirm it is clean:
ctxgrdDrop the upstream/downstream fields from an asymmetric pattern and you get:
error[ddd.context-map-shape]: CONTEXTMAP-1: asymmetric pattern `Customer-Supplier` must declare both `upstream` and `downstream` roles
--> docs/ddd/context-maps/ordering-billing.md:4:0
|
4 | pattern: Customer-Supplier
| ^
|
help: add `upstream:` and `downstream:` fields naming which context is upstream and which is downstreamddd.context-map-shape also errors if depends_on does not resolve to
exactly two BOUNDEDCONTEXT ids – one endpoint, three endpoints, or a
non-Bounded-Context id all fail this check. A dangling reference (an id that
does not exist) or a circular map (A depends on B depends on A) is caught
separately, by the same core.dep-resolved and core.dep-cycle rules your
other dependency graphs already use – ddd.context-map-shape only adds the
cardinality and direction checks that core’s rules cannot express on their
own.
5. Narrow the vocabulary
Both allowlists – status/subdomain_type on Bounded Context, pattern and
the symmetric-pattern set on Context Map – are config, not hardcoded. Edit
ctxgrd.toml to fit your team’s actual vocabulary:
[BOUNDEDCONTEXT."core.allowed-values"]
status = ["draft", "active", "deprecated"]
subdomain_type = ["core", "supporting", "generic"]
[CONTEXTMAP."core.allowed-values"]
pattern = ["Partnership", "Shared Kernel", "Customer-Supplier", "Conformist", "Anticorruption Layer", "Open Host Service", "Published Language", "Separate Ways"]
[CONTEXTMAP."ddd.context-map-shape"]
exact_context_count = 2
symmetric_patterns = ["Partnership", "Shared Kernel", "Separate Ways"]Retype the arrays to drop patterns your team does not use, or add ones from a
different DDD vocabulary. exact_context_count stays at 2 for a strategic
Context Map – Evans’ pattern is a pairwise relationship – but is exposed if
you need a different cardinality.
6. Lint and iterate
ctxgrd # human-readable output
ctxgrd --format json | jq '.[] | select(.code | startswith("ddd"))'Exit codes follow the standard contract:
0– clean.1– diagnostics reported.2– kernel or config error.
A note on C4
A Bounded Context is a model/language boundary, not a deployment boundary –
one service can host two contexts, and one context can span services. If you
also run the c4 pack, a Bounded Context doc may optionally link to its
corresponding C4 diagram via core.requires-link, but ctxgrd does not enforce
a one-to-one mapping between the two. Keep them as separate concepts even when
one team happens to own both.
Next steps
- Rule packs – the full built-in pack catalog.
- Configuring namespaces – path claims and allowed-values in depth.
- Linting C4 architecture diagrams – the sibling id-less pack for deployment-level diagrams.