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

  • ctxgrd 0.51.0 or later.
  • A ctxgrd.toml in the repository root. If none exists, run ctxgrd pack add project-docs to create a baseline.

1. Add the pack

ctxgrd pack add ddd

This 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-run

Unlike 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 none

That 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:

ctxgrd

With 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:

ctxgrd

Drop 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 downstream

ddd.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