Tooling report
How the kit behaved while this example was built, on 2026-10-10, by one conductor and seven
workers following the plugin's skills. Written for the owner of the kit: nothing here is rounded
in the kit's favour. Raw data: docs/tooling/generator-log.jsonl (one line per generator run),
node scripts/measure-tooling.mjs (generated against hand-written lines).
Headline numbers
Section titled “Headline numbers”| Wall clock, from the statement of needs to a green gate and a recorded real run | about 1 h 25 min (06:33 to 07:58) |
| Generator runs that succeeded | 213 (one init, 212 generate), 77 s of generator time in all |
| Generator runs refused | 27, almost all by the glossary gate |
| Files in the example | 831 |
| Files a generator wrote and nobody touched | 133 (16 %) |
| Files a generator wrote and a worker then filled or changed | 584 (70 %) |
| Files written by hand | 106 (13 %), plus 8 derived documents |
| Lines kept from generators / lines written by hand | 19 577 / 21 937 (2 917 generated lines were removed or replaced) |
TODO(domain) markers written by generators |
656, all filled |
it.todo entries written by generators |
317, all replaced by tests |
TODO(platform) markers |
9 written, 4 left (listed under "Not built") |
| Tests | 1 288 unit and adapter tests in 226 files, 57 end-to-end tests in 10 files; the 267 adapter tests also pass on PostgreSQL 18 |
| Built | 32 commands and queries, 25 routes, 2 policies, 4 scheduled jobs, 5 projections, 14 integration events, 5 integration event handlers, 2 gateways |
| Sub-agents | 7 (platform, identity, two for scheduling, notification, analytics, end-to-end), about 2.0 million tokens between them |
| Generated files that needed a hand fix for a defect or a gap of the kit | about 40 (listed below) |
| Defects found in the kit's packages | 2 confirmed, with reproductions |
How to read the line counts: "generated lines kept" includes what init writes (configuration,
both pipelines, the Keycloak realm: 6 500 lines of project files) and the skeletons of generated
specs; "by hand" includes about 12 000 lines of tests and 3 000 lines of documents (this report, the commit plan, the transcript). In the four layers and the published
language of the contexts the split is 4 600 generated lines kept against 5 300 by hand: the
generators wrote the shape of every file and close to half of the text. Files that a generator
wrote and a worker deleted (the readers of query --list, friction 3) are counted nowhere.
Wall clock per phase
Section titled “Wall clock per phase”| Phase | Clock | Who | Notes |
|---|---|---|---|
| Reading the architecture reference, the skills, four package READMEs | 06:33 to 06:35 | conductor | about 4 000 lines read before anything was written |
| Needs, discovery, contexts, glossary, context map | 06:35 to 06:38 | conductor | five artefacts |
init, npm install, four contexts with their 86 glossary terms |
06:38 to 06:41 | conductor | the bare project needed five hand fixes to be green inside this workspace (frictions 7, 13) |
| Skeleton and contracts: 8 aggregates and repositories, 14 integration events, 2 gateways, 2 policies, 4 commands with their jobs | 06:41 to 06:46 | conductor | 50 generator runs, payloads written by hand |
| Slices, plan, briefings | 06:46 to 06:49 | conductor | |
| Platform: persistent tuples, schema, combined authorizer, read-only impersonation | 06:46 to 06:55 | 1 worker | 157 k tokens |
| Contexts, in parallel | 06:49 to 07:23 | 5 workers | analytics 12 min (215 k tokens), notification 17 min (288 k), identity 19 min (284 k), scheduling calendars 26 min (352 k), scheduling appointments 32 min (455 k) |
| Integration on the trunk: bindings, projections, statuses, decision record | 07:18 to 07:26 | conductor | |
| Cross-context journeys, and the workaround of the read-model defect | 07:26 to 07:39 | 2 workers | 244 k and 20 k tokens |
| Real run: compose stack, Keycloak tokens, demo script, three passes | 07:27 to 07:46 | conductor | |
| Documents, measurements, commit plan | 07:46 to 07:58 | conductor |
Generator runs, per kind
Section titled “Generator runs, per kind”| Kind | Runs | Output compiled and passed the analyzers untouched |
|---|---|---|
init |
1 | no: one unused import fails the lint until the first context exists; knip flags resilience/own-policy.ts until an adapter uses it |
context |
4 | yes |
aggregate, repository |
8, 8 | yes; the generated fake of every repository had to be changed later (friction 6) |
integration-event |
14 | yes, until the payload is written: the publisher then stops compiling until it is mapped (expected, but a contract cannot be committed green alone) |
gateway |
2 | yes, until the contract gets its first method: the {} placeholder bound in both platform modules stops compiling (friction 5) |
policy |
2 | yes |
command |
26 | yes |
query |
6 | plain queries yes; all four --list runs had their reader port, in-memory reader and fake deleted (friction 3) |
route |
24 | yes; path parameters got no DTO in two cases, empty body DTOs were generated in three |
job |
4 | yes |
value-object, domain-error, domain-event, domain-service |
20, 46, 8, 2 | yes |
event-handler |
12 | yes |
integration-handler |
5 | yes |
port |
8 | yes |
adapter |
3 | compiled; two of three were emptied of their generated transport (friction 10) |
e2e |
7 | compiled; all seven were rewritten: the generated baseline assumes a role named admin and the tuple context:<name>#operator (friction 4) |
worker |
1 | yes |
adr |
2 | yes; --deviation writes the deviations entry but not the configuration entry it justifies |
Hand fixes of generated code
Section titled “Hand fixes of generated code”| Files | What | Why |
|---|---|---|
package.json, scripts/tsc.mjs |
the scripts call the compiler through a resolver | the generated scripts name node_modules/@typescript/native/bin/tsc; in a workspace the package is hoisted, and it exports no path to its executable |
package.json, scripts/kit.mjs |
the scripts call the CLI with node |
defect 2 below |
.dependency-cruiser.workspace.mjs, package.json |
a wrapper configuration with preserveSymlinks |
linked packages resolve to ../../packages/*, so every rule about allowed packages misfires (8 violations on an empty project) |
knip.json |
two entries | consequences of the two lines above |
docker-compose.yml, .env.example |
project name, loopback bindings, unusual ports, local image tags | the generated file binds PostgreSQL, the cache and Keycloak on every interface at their default ports, and names the project after the directory |
12 files of src/platform/auth, both platform modules, compose-application.ts, test/e2e/support/start-application.ts |
the tuple store is created from the persistence and bound under its port | the generated store is an in-memory module singleton behind a TODO(platform); making it persistent means rewiring every file that imported it (friction 4) |
platform.module.ts, platform-test.module.ts |
the two read contracts bound to implementations | friction 5 |
compose-application.ts |
five projections named in the generated contextModule(...) lines |
friction 2 |
database/schema.ts |
createAnalyticsTables added |
no repository generator ran in a context that only has read models, so nothing registered its tables |
audit/list-audit-entries.route.ts |
impersonatedBy added to the view |
the generated view of an audit entry omits who impersonates, which the trail records |
observability/create-logger.ts |
an optional destination | no way to read log lines in a test |
| 6 repository fakes | snapshots replaced by a clone through rehydrate |
friction 6 |
| 7 end-to-end specs, 3 generated spec files | rewritten, or split to respect clean-stepdown and the 100-line ceiling |
friction 4; a generated describe grows past the ceiling once its it.todo entries are filled |
| 2 adapters | generated transport, retry and timeout removed | friction 10 |
Written by hand where a generator would be plausible
Section titled “Written by hand where a generator would be plausible”- Five projections, their five
KnexReadModelStoreadapters, tables, fakes and specs (friction 2). - The list queries around
Criteria,scopedandcriteriaQuerySchema(friction 3). - The implementations of the two read contracts on the upstream side (friction 5).
- The table of error statuses (
src/platform/http/error-statuses.ts):domain-errortakes a--codeand nothing says which status it answers; everything is 422 until someone notices. - The relationship schema, the permission mapping and the resource of every
@Requires. - Path parameter DTOs of two routes; nested DTOs.
Frictions, ranked by what they cost
Section titled “Frictions, ranked by what they cost”- A read model written by the Knex adapter fails on its second write with a real clock
(defect 1 below). Every handler spec passed; the first request through the composed
application answered
422 CONCURRENCY_CONFLICT. Cost: the only red state of the build that needed diagnosis, two agents, a workaround in the shared kernel used by five projections. Proposal: read the column as theDatethe driver gives; add a millisecond case toreadModelStoreContract; give the clocks of generated specs milliseconds. - The CLI does not know projections. The kernel and three READMEs present
@Projectioninapplication/projections/as the way to keep a read model;checkrefuses the directory (tree/closed),docsleaves the classes out of the barrel, so a projection is silently not subscribed, and there is no generator. Cost: a deviation with its decision record for a documented feature, three configuration entries by hand, five registrations by hand in generated lines, and four workers each discovering it. The README of@ddd-ts-kit/diadmits it in one sentence; the architecture reference does not list the directory. Proposal:generate projection <context> <Name> --on <event>...(class, port, store adapter, table, fake, spec), the directory in the canonical tree, the role in the barrel. generate query --listwrites another list than the one the READMEs teach. It gives a reader port taking(scope, page), an in-memory reader named*.repository.tsand a fake; the kernel, http and knex READMEs teachCriteria,ReadModelStore,scopedandcriteriaQuerySchema. All four runs ended with the generated port, reader and fake deleted (15 files) and the query, handler and route rewritten. Proposal:--list --criteriathat writes the criteria definition, the read-model port and the route withcriteriaQuerySchema, or make it the default.- The
rebacbootstrap is a placeholder, and the generators keep writing for it. The store is in memory and a singleton; every@Requiresnamescontext:<name>; the generated end-to-end baseline relatescontext:<name>#operatorand expects 403 from a stranger, which is false as soon as the model is real; integration event handlers need a system grant written by hand;Access.authorize(actor, permission, aggregate)needs aresourceOfthat no skill mentions and that handler specs on roles cannot detect. Cost: one worker for the platform (157 k tokens), 12 files rewired, 7 end-to-end specs rewritten. Proposal: ship a KnexRelationTupleStorein@ddd-ts-kit/knex(the contract suite exists) and haveinitbind it through the persistence;--resource <type>:<field>oncommandandquery; a rule that lets a system actor operate its own context;resourceOfin the access-control skill. - The upstream side of a gateway is nobody's.
generate gatewaybinds the contract to{}in both platform modules: it stops compiling at the first method of the contract, and a declared implementation in the upstream context is then "bound twice". Two workers found two different ways out (an undeclared class bound by the platform;replaces: true). Proposal: generate the implementation in the upstream'sinfrastructure/with a declaration, and bind an inert, typed stand-in only in the test module. - The generated repository fake loses value objects.
snapshots()goes throughstructuredClone, which turns the value objects the aggregateTODOasks for into plain objects. Six fakes fixed the same way by four workers. Proposal: generate the fake with{ clone: rehydrate }from the start. - A generated project does not survive a workspace. Three fixes before the first green
gate: the compiler path, the CLI binary (defect 2), dependency-cruiser following links.
Proposal: scripts that resolve their tools (
ddd-ts-kit tsc),preserveSymlinksin the generated configuration, an executable bit set by the build of the CLI. - A refusal loses its reason twice.
ForbiddenError.reason(the policy's name) is not in the HTTP problem, and the denied decision of a policy asked inside a handler is written in the unit of work that the refusal rolls back: the trail keepscommand failed FORBIDDENonly. The "403 with the policy's code" asked of this example could not be delivered. Proposal: an optionalreasonmember in the problem; record decisions outside the unit of work, as the command middleware does for failures. - The glossary gate costs a run per word. 27 refused runs:
List,Read,Get, the action word of a permission, the wordPracticein scheduling. The message is clear, but it names one missing word at a time, lists the--termof the refused run as existing, andLastManagerwas refused bylanguage/no-technical-namealthoughManageris a glossary term of that context. Proposal: report every missing word at once; let the glossary overrule the technical-name list; treatList,Get,Readas verbs of queries without a term. generate adapterknows one adapter: a remote client. A port whose adapter writes relation tuples, or logs a mail, gets a transport with retry and timeout to delete, and the nameremote-...stays. Theevent-handlermarker says "execute a command" where the documented way to keep relationships in step is to call a port. Proposal:--kind local.- One consumer per event and context.
integration-handlernames its handler after the event and takes no name: a second reaction of the same context to the same event cannot be generated. Reminders are therefore planned by a reaction to "notification sent", and a notification that is dead-lettered takes its reminder with it. Proposal:--name. - Generators and
docsrewrite other contexts. Agenerate portinidentityrewrote the barrel andCONTEXT.mdofanalyticsandscheduling; with workers sharing a tree this turned a neighbour's specs red twice for a few seconds. With worktrees it would have been a merge conflict in generated files.--local-onlyexists, but the help says these kinds write nothing shared. - Smaller ones, each a few minutes:
tree/context-docread the word "generic" in the sentence under## Classificationas a second classification; a policy generated before its command gives the permission the policy's name as description; class-validator payloads cannot describe a field (class-validator-jsonschemais not allowed in a published language, the skill asks for descriptions); the ESLint preset declares no Node globals for.mjsscripts (fetch,URL,Buffer);vitest.config.tsruns adapter specs in parallel on a sharedDATABASE_URL, where they truncate each other (the end-to-end configuration guards against it);generate e2enames its file after the use case without the context;generate routesays "create it withgenerate command" for a missing query; a log line of the bus middleware carries the keymessagetwice; the failure lines of the event bus carry no correlation id; the bootstrap skill deletes the glossary and the draft map, so the path cannot be replayed (kept here indocs/architecture/before-bootstrap/).
Contradictions between a skill, a README and the tool
Section titled “Contradictions between a skill, a README and the tool”| Says | Against |
|---|---|
README of @ddd-ts-kit/auth: "domain events are dispatched after the commit", so a tuple written by an event handler is a second step |
architecture reference and the kernel: events leave with the save, inside the unit of work. The tool does the second; the example relies on it and proves it |
README of the kernel: a row interface exported next to a read port |
ports/abstract-class refuses it (its message says to use a type) |
README of @ddd-ts-kit/di: contextModule takes a projection |
the generated barrel never lists one |
README of @ddd-ts-kit/knex, read models |
keyColumn is required when the key is not id; not shown |
| integration-event skill: describe each field of the payload | impossible with class-validator inside the allowed packages |
| bootstrap skill: "the empty project is green" | true only after the first generate context |
| parallel-work skill: four workers at most | five context workers and one platform worker ran at once here on a shared tree without trouble; the limit that mattered was the number of hands in one context (two) |
Rule violations met, and whether the message said what to do
Section titled “Rule violations met, and whether the message said what to do”| Rule | Met by | Message |
|---|---|---|
clean-stepdown, function size, the 100-line ceiling of a describe |
every worker, in specs | clear, with the fix; the generated spec skeleton invites the violation |
language/glossary-term |
27 runs | clear, one word at a time |
language/no-technical-name |
LastManager; importing the kernel's own ReadModelStore in a handler ("Model") |
clear, but wrong in both cases |
tree/closed |
the projections | clear, with the fix (an extra directory) |
declarations/role-declared |
an in-memory reader left next to its Knex twin | clear |
ports/abstract-class |
a row interface in a port file | clear |
clean-no-escape-hatch |
a type guard written with an assertion | clear, with three alternatives |
| wiring: "bound twice" | the gateway contracts | names the port and the modules |
What was pleasant
Section titled “What was pleasant”- One
initcommand, then a compiling, linted, tested skeleton with both pipelines, a realm, a compose file and agent rules: the stack questions were answered before any code. - The
nextlist of every generator: workers opened those lines and little else. - The contract suites: fakes and adapters held to the same behaviour without writing it.
- PGlite: adapter specs and the exclusion constraint (
btree_gist) ran in process on the first try; the same specs then passed on PostgreSQL 18 with real concurrent transactions. composeApplicationon a real database in a spec,expectSpanTree, and one trace from the HTTP request to three consumers without a line of tracing in a context.- The messages of the linter and of the wiring errors say what to do instead.
- The real run passed its first six steps at the first attempt, against real Keycloak tokens.
Parallel work: what happened
Section titled “Parallel work: what happened”| Worker | Scope | Generator runs | Result |
|---|---|---|---|
| platform | src/platform/auth, buses, test support |
1 | done in 9 min |
| identity | the context | 47 | slices 11 to 17, 286 tests, 36 end-to-end tests |
| scheduling-calendar | Calendar, Waitlist, practitioners, day view | 50 | slices 18 to 21, 28, 29 |
| scheduling-appointment | Appointment, Scheduler, lists, export | 34 | slices 21 to 27, 30; recalled once for the read-model defect |
| notification | the context | 22 | slices 31 to 34, 184 tests |
| analytics | the context | 7 | slices 35 to 37, 94 tests |
| end-to-end | test/e2e/journey-*, integration fixes |
0 | 17 tests, three integration faults fixed |
There is no git in this build, so the workers shared one tree instead of a worktree each, and
every generator run was serialised behind a lock (scripts/kit.mjs). Collisions: none lost
work. Near-collisions: the port SchedulingRelationships, created by one half of scheduling
and completed by the other, as briefed; two transient red states of a neighbour's specs
(friction 12, and one broken import for a few seconds). Contracts first paid: no worker waited
for another and no payload changed after the workers started. What did not pay: asking each
context for its own end-to-end specs before the composition existed (four of five workers could
not write them), and briefing the projections without a generator (each worker read the same
three README sections and wrote the same adapter).
Estimated reading per slice, from the workers' reports: the generated files of the slice (5 to
12 files, around their TODO lines), one or two README sections for anything beyond a plain
command, and once per worker the rules, the slices and four skills. The slices that cost
reading were the ones without a generator: projections (kernel, knex and di READMEs), criteria
lists (kernel, http, knex), the rebuild (kernel source).
Defects found in the kit's packages
Section titled “Defects found in the kit's packages”@ddd-ts-kit/knex, read models: the stamp is read back without its milliseconds.packages/knex/src/read-model/knex-stamped-rows.ts:33buildsnew Date(String(record['last_event_at'])); the driver answers aDate, andString(date)has a precision of one second.replace()then filters onlast_event_at = <truncated instant>and matches no row. Reproduction: with aKnexReadModelStoreon PostgreSQL or PGlite,upsertIfNewer(key, { eventId: 'a', occurredAt: new Date('2026-01-01T00:00:00.123Z') }, ...), then a secondupsertIfNeweron the same key with a later stamp: five attempts, thenConcurrencyError. Any stamp whose milliseconds are zero hides it, which is what fixed clocks give. Worked around insrc/shared-kernel/storable-stamp.ts(instant floored to the second, order carried by a version), with a regression spec per adapter.ddd-ts-kit(the CLI):dist/bin.jsis not executable after the build. In this repository,npm ci && npm run build, then any script of a workspace that calls theddd-ts-kitbinary answerssh: 1: ddd-ts-kit: Permission denied: the compiler writes the file without the executable bit. A published package is not affected (the package manager sets the bit at install). Worked around by calling the CLI withnode(scripts/kit.mjs).
Suspected, not confirmed as defects: the generated view of audit entries omits
impersonatedBy (template); the bus middleware logs message twice in one JSON line.
Not built, or built smaller than asked
Section titled “Not built, or built smaller than asked”- The policy's reason code in the HTTP answer of a refused cancellation: the answer is
403 FORBIDDENnaming the permission (friction 8). The policy's decision is covered by its own spec and by the journey spec; it is not in the audit trail. - A metrics adapter (
TODO(platform)insrc/platform/observability/create-metrics.ts), the declaration of audited fields (src/platform/audit/create-audit.ts), and the two remote gateway adapters for the day a context is extracted: the fourTODO(platform)left. - The mail port has its development adapter (it writes to the log) and a fake that fails on demand; no SMTP catcher.
- Occupancy is held minutes and counts per calendar and day, not a rate against opening hours; the nightly verification walks UTC days.
- Leaving a waitlist, listing members and delegations, an organization above practices.
@Cachedand@CircuitBreakerare not used: the decorators shown are@Retryand@Timeouton the mailer; the binding-level form is the platform's policy around everyexternalandgatewaybinding.- No entry point runs
ProjectionRebuildin production: it is exercised by a spec. - The fresh-clone sequence
npm ci && npm run build && npm run example:verifywas checked piece by piece (npm ci --dry-runagainst the lockfile, the build, the gate), not from a second clone.
Open business questions the workers left
Section titled “Open business questions the workers left”Taken as written in the code, to confirm with the product owner: a requested appointment has
24 hours to be confirmed; an offer from the waitlist ignores the patient's limit; a waiter
leaves the waitlist once offered a slot; a failing offer fails the cancellation that freed the
slot; opening a practice also announces its first manager as a member; time zones without an
area (UTC) are refused; a booking made less than 24 hours ahead is reminded at the next run of
the job; a contact never changes after the first registration; held appointments count held,
completed and no-show together.
After delivery: analytics computed at read time
Section titled “After delivery: analytics computed at read time”2026-10-10, after the build above was delivered. Everything above this section is the report of the build and is left as it was written; where it speaks of five projections, four scheduled jobs, the occupancy register or the nightly verification, it describes the state before this change.
The decision (product owner). In analytics, keep one row per fact and compute at read
time, instead of maintaining figures that events increment or recount.
What changed.
- Removed: the projection
analytics.occupancy, the portOccupancyRegisterwith its Knex adapter and its fake, the tableanalytics.occupancy, the commandVerifyOccupancywith its handler, the jobanalytics.verify-occupancy, the permissionanalytics:occupancy.verify, the grant of the job's system actor, the line of the job insrc/platform/job-settings.ts(and with it the keyJOB_ANALYTICS_VERIFY_OCCUPANCY_ENABLED, which no file of the example set), their five specs, and the value objectDaywith its errorInvalidDayError, which only the verification used. - Added: the port
OccupancyReader(generate port), its Knex adapterknex-occupancy-reader.repository.ts(by hand: one grouped statement over the outcomes), its fake (the domain service over the outcomes of a register) and one contract suite both pass,test/integration/occupancy-reader.contract.spec.ts, 17 cases for each. - Kept: the projection
analytics.outcomes, idempotent and safe against order by the stamp of its rows, its contract and its rebuild spec; the domain serviceOccupancyMeasureService, now the definition the statement is held to; the queryListOccupancy, its route and its response, unchanged for a client (openapi/openapi.jsonis byte for byte the same). - Schema: the creation of the tables was changed, with no migration added, since no database of
this example is deployed:
analytics.occupancyis no longer created,outcomes.calendar_idis declaredCOLLATE "C"(the lines are ordered by calendar and by a key made of the calendar and the day), andoutcomesgains the index(practice_id, day, calendar_id). A database created before this change has to be created again. - One behaviour differs: a calendar and day that an appointment left (rescheduled to another day) and that holds no other appointment has no line any more, where the stored model kept a line of zeros. A day whose appointments were all cancelled still has its line.
Counts, context analytics.
| Before | After | |
|---|---|---|
| Files | 47 | 36 |
| Lines | 2 039 | 1 732 |
Source files and lines (outside test/) |
26, 789 | 20, 579 |
| Test files and lines | 20, 1 158 | 15, 1 028 |
CONTEXT.md |
92 | 125 (the new section "How occupancy is measured") |
| Read models, tables | 2, 2 | 1, 1 |
| Scheduled jobs, commands, permissions | 1, 1, 2 | 0, 0, 1 |
| Consumers of each appointment event | 2 | 1 |
Outside the context: three lines less in src/platform/ (compose-application.ts,
auth/relate-system-actors.ts, job-settings.ts), four less in
test/e2e/journey-observability.e2e.spec.ts (the trace of a booking lost a consumer). The gate
counts 1 282 unit and adapter tests (1 288 before) and the same 57 end to end. The 210 source
lines the context lost, net, were the part that defended a copy: recounting on every event, choosing between
the fact and a later outcome already stored, a stale write left to the night, the night job.
The gate and the real runs. npm run verify is green. The contract of OccupancyReader
passes on PGlite and on PostgreSQL 18 (it reads DATABASE_URL; on an empty table the planner already serves the
grouped statement from the new index). scripts/demo.mjs was played again for real on an empty
database; docs/demo-transcript.md keeps the first run and adds the two steps that changed.
Two things met on the way, neither caused by this change:
npm run test:postgresruns most contract suites on PGlite even withDATABASE_URLset: only the specs that pass the variable toopenTestDatabasereach the server (two ofscheduling, and now the contract ofOccupancyReader). The headline "adapter tests also on PostgreSQL 18" above is true of those. Proposal for the example:openTestDatabase()readsDATABASE_URLitself.- On PostgreSQL 18,
scheduling/test/integration/concurrent-booking.spec.ts, "refuses a booking that starts in the buffer of one made at the same instant, and takes one that does not touch them", fails about one run in two (409 where 201 is expected): when the booking in the buffer wins the race, the third one falls in its buffer. A defect of the spec, left as found.
What this says for the kit.
- There is no supported way to write an aggregate read. The README of the kernel says it
in one line ("No aggregation") and stops there,
ReadModelStore.listanswers rows, andgenerate query --list(friction 3) writes neither. The pieces are nevertheless almost enough:planCriteriavalidates the read and joins the scope,predicateToKnexrenders the filters,criteriaToKnexthe keyset, the order and the window,evaluateCriteriagives the fake for free once the lines are computed in memory. What had to be found by reading the source of@ddd-ts-kit/knex: the filters and the scope must go inside the grouped sub-query and the keyset, the order and the window outside, which is done by callingcriteriaToKnex(outer, { ...plan, filter: undefined }, columns); the total of an offset page is a count of groups, written by hand. Proposal: (a) a section "Aggregate reads" in the READMEs of the kernel and of each adapter with this recipe; (b) in the adapters,windowToKnex(builder, plan, columns)(keyset, order, window, no filter) next topredicateToKnex, so that nobody edits a plan; (c) indefineCriteria, a field declaredcomputed: true: never filterable, never the target of a scope mapping, refused when the definition loads instead of when a statement is built (today the adapter refuses it late, because the field has no column among the grouped ones); (d)generate query <context> <Name> --list --grouped-by <fields>writing the port, the adapter skeleton, the fake overevaluateCriteriaand the shared contract spec. - A definition and its SQL can be held equal by the contract suite, and nothing says so.
The pattern used here (a domain service that measures, a fake that calls it, an adapter that
translates it, one suite with hand-computed figures for both) is what makes "computed in the
database" safe to maintain.
@ddd-ts-kit/testing/contractshascriteriaContractandreadModelStoreContract; proposal: document this third use in the testing README, with the cases an aggregate must cover (a group that empties, rounding per row or per group, a row marked deleted by a rebuild, the total past the last page). - No generator writes a persistence adapter for a port of the application.
generate adapteronly writes intoinfrastructure/external; the Knex reader was written by hand after the one ofnotification. Proposal:generate adapter <context> <Port> --kind persistence, declared with@Repository(Port, [KnexContext]), with its contract spec over the fake and the adapter. - Nothing removes what a generator wrote, and nothing notices what is left. Removing a
command and its job meant deleting files and editing four generated regions by hand
(
ddd-ts-kit:permissions,ddd-ts-kit:system-grants,ddd-ts-kit:jobs, the projections named incompose-application.ts);docsthen repaired the barrel and theCONTEXT.md. Checked on purpose: with the grant of the removed job's system actor and its line of job settings put back,ddd-ts-kit check, the compiler and an end-to-end spec all stay green. Proposal:checkcompares the names in those regions with the declared jobs and permissions (a ruleconfig/no-orphan-registration), and addd-ts-kit remove <kind> <context> <Name>that undoes whatgeneratelisted increatedandupdated. - Collation is easy to get wrong in a computed key. The lines are ordered by a key built
from two text columns; both must be
COLLATE "C"or PostgreSQL cannot decide the collation of the key. The README of@ddd-ts-kit/knexcovers sorted columns, not expressions. Proposal: one sentence there.
The two generator runs of this change (generate port analytics OccupancyReader, docs) are
in the log kept by scripts/kit.mjs; docs/tooling/generator-log.jsonl is the copy made at
the end of the build and was not refreshed.
After delivery: domain events dispatched at the end of the unit of work
Section titled “After delivery: domain events dispatched at the end of the unit of work”2026-10-10. The kit changed when a domain event reaches its handlers: no longer after each
save, but once the command handler has returned, inside the transaction and before its commit,
with an after-commit phase for what no transaction covers. The guarantee: an effect of a
domain event exists only if the state change that recorded it is committed. What it took here,
and what the tools said.
What changed in the example:
- No handler was rewritten. The twenty-six domain event handlers are declared, in the default phase, and their dependencies are the command bus, the outbox publisher, a repository, or a port of relationships. Unit and integration specs passed untouched (1282 tests): they call the handlers directly or through the test application, and none relied on a handler running between two saves.
- Two adapters moved, and that is the decision.
RemoteIdentityRelationshipsandRemoteSchedulingRelationshipswere whatgenerate adapterwrites: underinfrastructure/external/, kindexternal, behind seven handlers that run before the commit. The new ruledomain-events/transactional-before-commitrefuses that shape, and it is right to: a remote write before the commit survives a rollback. Read closely, the store is not remote:RelationTupleStoreis bound toKnexRelationTupleStore, a table of the same database written in the transaction of the command (ADR-0001). The honest fix was to say so: the adapters are nowTupleIdentityRelationshipsandTupleSchedulingRelationships, ininfrastructure/persistence/, declared with@Repository, without the empty resilience policy they carried. The handlers stay before the commit, so a relationship commits or rolls back with its fact, which neither theafter-commitphase (at most once: a right never withdrawn after a crash) nor an integration event consumed by the same context (a right that lags behind its fact) would give. The reasoning, and what to do the day the tuples move to a remote service, is in theCONTEXT.mdofidentity. A new integration spec,relationships-commit-with-the-fact.spec.ts, proves it on PostgreSQL. - Seven span trees changed, as designed. The handlers left the span of the save: under the
command come the saves, then
unit-of-work before-commitwith the events and their handlers.identity-remove-membershows two such spans, the second for the delegation the first handler ended. The spans of the relationships are namedrepository ...Relationships.*instead ofexternal .... - One order changed in
journey-observability. The consumer ofnotificationopens the unit of work (the mark of the event and the state change commit together) and sendsSendNotificationCommand, which joins it. The factnotification.notification.sentis therefore dispatched at the end of the consumer's unit of work, after the command returned:unit-of-work before-commitis a sibling of the command span underprocess ..., and the log line ofPlanReminderCommandnow follows the one ofSendNotificationCommand. Both still commit or roll back together.
What the tools did well: the rule and the checker agree with the code once the adapter says
what it is, docs repaired both barrels and both CONTEXT.md after the move, and
expectSpanTree printed the expected and the found tree side by side, which made each of the
eight expectations a reading exercise.
Frictions:
generate adapternames every adapterRemote...and files it underinfrastructure/external/, so an adapter that writes in the transaction starts its life declared as an outside system. It is friction 3 of the previous section seen from the other side: withgenerate adapter <context> <Port> --kind persistencethe two adapters would have been declared right from the start, and the new rule would have had nothing to say.- Nothing moves a generated file. The two moves were
mv, two class renames, two spec renames and adocsrun;ddd-ts-kit checkthen confirmed the tree. SendNotificationHandlercallsMailer.deliverinside the transaction of its command. The new rule judges domain event handlers only, so it says nothing, and the handler guards against a second mail withfindByCause. A mail can still leave for a notification whose commit failed. It is the same question one level up (an effect outside the database before the commit) and no rule holds it for command handlers. Left as it is, on purpose: out of the scope of this change.docs/demo-transcript.mdis a recording made before this change: its span names (external SchedulingRelationships.recordAppointment, nounit-of-work before-commit) are those of that run, and were left as recorded.
The generator run of this change (docs) is in the log kept by scripts/kit.mjs;
docs/tooling/generator-log.jsonl was not refreshed.
2026-10-10: every import by its alias (Node subpath imports)
Section titled “2026-10-10: every import by its alias (Node subpath imports)”The example imported relatively everywhere (../../../domain/...) although its config named an
alias, @/, that only tsconfig.json and the vitest configs knew: an alias import would have
type-checked and then failed under node dist/src/main.js. The alias is now #src/, a Node
subpath import declared once under imports in package.json, with #test/ and #scripts/
next to it, and the rule imports/alias-only refuses a relative import.
What was done, in order:
- By hand, the declarations
initwrites for a new project:importAlias#src/in the config, theimportsofpackage.json,--conditions=ddd-ts-kit-sourcein thedevscript,pathsremoved fromtsconfig.json,ssr.resolve.conditionsin place ofresolve.aliasin the two vitest configs.ddd-ts-kit checknamed each one that was missing (imports/alias-declared), with the text to write. node scripts/kit.mjs depcruise-config --write: the generated graph configuration resolves under the source condition.npx eslint . --fix: 2097 imports rewritten in 557 files (2019 undersrc/, 73 undertest/, 5 underscripts/), the four barrels included. The checker counted the same 2097 before the fix and none after. Thennpm run format: 25 files re-wrapped, since an alias path is longer. A secondeslint . --fixchanged no file.migrations/0001_initial.jsnames the compiled schema by#src/platform/database/schema.jsinstead of../dist/src/...: under the plainnodeof the knex CLI the alias answersdist/.
Checked after it: npm run typecheck, npm run build, 1285 unit and adapter specs, 57
end-to-end specs, dependency-cruiser (759 modules, no violation), knip, docs:check. Plain
node links the 463 compiled modules on Node 22.23 and Node 24.21 with no alias left
unresolved; node dist/src/main.js, the worker and tsx on src/main.ts each run up to the
connection to PostgreSQL, which no server answered on this machine: the full start with the
compose services was not replayed.
What the tools did well: the fix needed no decision, file by file, and the formatter absorbed
every long line; no file passed 350 lines and no function 30, since imports count in neither.
docs had nothing to rewrite after the fix: the barrels ESLint fixed were already what the
generator writes.
Frictions:
- Three tools load the compiled copy in silence when the condition is missing: tsx, vitest
and jest resolve an alias to
dist/asnodedoes, so a stale build would be tested instead of the sources. That is whatimports/alias-declaredchecks in the scripts and in the runner configs, and why the condition is in each of them. - Renaming the alias is not a command. The config, the
importsentry and the prefix in the sources are three edits; the checker names the first two. docs/demo-transcript.mdanddocs/tooling/are recordings made before this change and were left as recorded.
2026-10-10: coverage as a governed setting, and every rule proven on this application
Section titled “2026-10-10: coverage as a governed setting, and every rule proven on this application”What changed here
Section titled “What changed here”vitest.config.tswrites no threshold any more: it spreadsgovernedCoverage(mode)of@ddd-ts-kit/testing/coverage, which reads thecoveragekey ofddd-ts-kit.config.json. This application sets no rate, so the reference applies: domain 90/85/90/90, application 85/80/85/85, presentation 80/70/80/80, infrastructure 70/60/70/70 (statements, branches, functions, lines), to each context on its own.npm run verifynow runsnpm run test:coverage(every spec once, the four layers measured) where it rannpm run test: the thresholds are enforced by the gate a developer runs, not only by the pipeline.plugins: [decoratorCoverage()]invitest.config.ts: this application compiles withemitDecoratorMetadata, and the transform guards every type a decorated class names withtypeof Port === "undefined" ? Object : Port, a branch no test takes.
What the measure found
Section titled “What the measure found”Before this change the pipeline script ci:test:unit of this application failed: the branch
rate of application/ was 74.1 % for a floor of 80 %, and verify did not say so because it
measured nothing. Two causes:
- The compiler, not the code. Most of the uncovered branches of
application/were the metadata guards above, one per port of every declared handler. WithdecoratorCoverage()the branch rate of the fourapplication/layers is between 98.68 % and 100 %. - Three real gaps, each closed by a spec, none by a lower threshold:
analyticsRoutes,identityRoutesandnotificationRouteswere called by the end-to-end specs only, which are not measured: each context has a<context>.routes.spec.tsthat holds its list of routes (no two routes answer the same method and path, nor share an operation id).generate contextnow writes that spec.grant-delegation.route.ts: no spec sent a delegation without an end. Added: "hands the use case a delegation without an end when the client gives none".
Rates on 2026-10-10 (statements, branches, functions, lines, in %)
Section titled “Rates on 2026-10-10 (statements, branches, functions, lines, in %)”| Context | Layer | Statements | Branches | Functions | Lines |
|---|---|---|---|---|---|
| analytics | domain | 100.00 | 100.00 | 100.00 | 100.00 |
| analytics | application | 100.00 | 100.00 | 100.00 | 100.00 |
| analytics | presentation | 100.00 | 100.00 | 100.00 | 100.00 |
| analytics | infrastructure | 98.33 | 85.71 | 100.00 | 98.28 |
| identity | domain | 100.00 | 100.00 | 100.00 | 100.00 |
| identity | application | 100.00 | 100.00 | 100.00 | 100.00 |
| identity | presentation | 100.00 | 100.00 | 100.00 | 100.00 |
| identity | infrastructure | 100.00 | 89.58 | 100.00 | 100.00 |
| notification | domain | 100.00 | 100.00 | 100.00 | 100.00 |
| notification | application | 100.00 | 100.00 | 100.00 | 100.00 |
| notification | presentation | 100.00 | 100.00 | 100.00 | 100.00 |
| notification | infrastructure | 98.32 | 83.33 | 100.00 | 98.31 |
| scheduling | domain | 99.37 | 96.95 | 99.08 | 99.34 |
| scheduling | application | 99.76 | 98.68 | 100.00 | 99.76 |
| scheduling | presentation | 100.00 | 94.44 | 100.00 | 100.00 |
| scheduling | infrastructure | 100.00 | 92.06 | 100.00 | 100.00 |
These rates say that the code ran under a spec. What the specs assert is another matter, held by their names and by the end-to-end journeys.
Every rule of the kit, broken once in a copy of this application
Section titled “Every rule of the kit, broken once in a copy of this application”npm run example:conformance, at the root of the kit, copies this application to a temporary
directory, installs the kit in the copy as a registry would, and for each of the 67 rule ids
of the catalogue writes one minimal violation and reads what the tool that owns the rule
says: ddd-ts-kit check, ESLint with the preset, Biome. A rule held by both the checker and
ESLint has to be reported by both, on the file that breaks it, with nothing else beside it
than the companions the case declares. The untouched copy has nothing to report to the
checker, ESLint, Biome, dependency-cruiser, knip and the type checker. No rule is declared not
applicable to this application. The suite fails when a rule of the catalogue has no case, so a
rule added to the kit is applied here or the pipeline says so. It runs in about 40 seconds,
after the gate of this application, in npm run example:verify.
Deviations of this application, reviewed
Section titled “Deviations of this application, reviewed”Three entries, one decision: application/projections is an extra directory of scheduling,
notification and analytics (ADR 0002). Still needed: the kernel has a @Projection role
and the canonical tree of the checker does not know its directory. The removal condition of
the record is unchanged. No rule is lowered, no package is added to an allow-list, no source
carries a suppression or a coverage ignore comment.
Limits
Section titled “Limits”- The generator lock of
scripts/kit.mjsmoved from the temporary directory of the system to the state directory of the script: nothing is left behind there. - The end-to-end specs are not measured: a route reached only end to end counts as uncovered.
2026-10-10: the alias becomes @/, declared under tsconfig paths and rewritten at build
Section titled “2026-10-10: the alias becomes @/, declared under tsconfig paths and rewritten at build”The section "every import by its alias (Node subpath imports)" above is kept as it was written:
it describes the mechanism of that day, a #-prefixed subpath import declared in
package.json and resolved by Node under a resolution condition. The owner of the kit rejected
that spelling for @/, without a src segment. Node cannot resolve @/ by itself, so the
mechanism changed: the alias is declared under paths in tsconfig.json, and the build ends
with ddd-ts-kit build-aliases, which rewrites each alias of dist/ into a relative path.
What was done here, in order:
- The prefix of every alias import, replaced in place: 2101 specifiers in 561 files (2023
under
src/, 73 undertest/, 5 underscripts/); 2055 name the source root by@/and 46 name the tests by$test/.npx eslint . --fixthen changed no file: the autofix ofimports/alias-onlyrewrites a relative import, and none was left.npm run formatre-wrapped 7 files, since the prefix is three characters shorter. - By hand, the declarations
initwrites for a new project:importAlias@/in the config,pathsintsconfig.json, theimportsfield removed frompackage.json, the resolution condition removed from thedevscript and from the two vitest configs, which now setresolve: { tsconfigPaths: true }, andbuildchainingnode scripts/kit.mjs build-aliasesafter the compiler. node scripts/kit.mjs depcruise-config --write: the generated graph configuration no longer names a condition and readstsconfig.json.migrations/0001_initial.jsnames the compiled schema by../dist/src/...again: the knex CLI runs it under plainnode, and nothing rewrites a JavaScript file at the project root.npm run docs:generatehad nothing to rewrite: the barrels were already what the generator writes under the new alias.
$test/ and $scripts/, not @test/ and @scripts/: a name such as @test/x is also the
name a package may bear, and dependency-cruiser was measured to resolve an installed package of
that name before the file of the project. $ cannot appear in a package name
(docs/tools/import-aliases.md).
Checked after it: npm run verify (types, lint, format, 1290 unit and adapter specs with the
coverage thresholds, 57 end-to-end specs, the checker with 0 finding in 724 files,
dependency-cruiser on 763 modules, knip, docs:check), then the 67 rule ids of the conformance
suite. The build rewrites 800 specifiers in 247 of the 467 emitted files; plain node links
every compiled module and starts dist/src/main.js and the worker up to their configuration
on Node 24.21 (alias-example.spec.ts, which CI also runs on Node 22). The full start with the
compose services was not replayed.
Frictions:
- A build is now two steps.
tscalone leaves the aliases indist/, which then stops undernodeat its first import.imports/alias-declaredreports a script that compiles without the rewrite, and the rewrite refuses an output it cannot finish. - An alias completed at run time cannot be rewritten.
scripts/events.tsof this example already built its path fromimport.meta.url; the template of the kit did not, and the rewrite refused it on the first generated project. scripts/demo.mjskeeps three relative imports: it is a JavaScript file Node runs as written, outside the TypeScript sources the rule reads.docs/demo-transcript.mdanddocs/tooling/are recordings made before this change and were left as recorded.
2026-10-12: imports without the .js extension
Section titled “2026-10-12: imports without the .js extension”The owner of the kit prefers an import that names its file and nothing else:
@/contexts/identity/domain/repositories/person.repository, not .../person.repository.js.
The kit now has a setting for it, importExtension (none or js), none for a new project,
and a config without the key is read as js. This application was moved with the tools a team
would use, in the order the checker gives:
"importExtension": "none"added toddd-ts-kit.config.json, by hand.ddd-ts-kit checkthen reported 2106 findings and each one said what to do: 2101imports/extension(one per import, in 561 files), 4declarations/barrel-current(the barrels no longer matched what the generator writes) and 1imports/alias-declaredontsconfig.json.tsconfig.json, by hand, as that last finding dictates:"module": "ESNext","moduleResolution": "Bundler","customConditions": ["node"].npx eslint . --fix, 45 seconds: the autofix ofimports/extensionrewrote the 2101 imports (2023 undersrc/, 73 undertest/, 5 underscripts/), the four barrels included, since a barrel is a file ofexport * fromlines.npm run formatat the root of the repository: 4 files changed in the whole tree, where a statement fits on one line again now that its specifier is three characters shorter.npm run buildat the root, thennpm run docs:generatehere:ddd-ts-kit docsanswered "Nothing to do" (the fix had already written the barrels as the generator does), and the OpenAPI and AsyncAPI documents came out identical.CLAUDE.mdand the "Imports" section of the README, by hand, asinitnow writes them.
Nothing else changed: no file under migrations/ (JavaScript that Node runs as written keeps
its .js), no script, no test configuration (vitest reads paths, which complete the
extension), not scripts/events.ts, whose one index.js is a path of the compiled output
built from import.meta.url.
Checked after it: npm run verify (types under the bundler resolution, lint, format, 1290 unit
and adapter specs with the coverage thresholds, 57 end-to-end specs, the checker with 0 finding
in 724 files, dependency-cruiser on 763 modules and 3429 dependencies, knip, docs:check),
then every rule id of the kit broken once in a copy of this application (75 specs, the new
imports/extension among them, written in the spelling the config of the example does not
ask for). The build still rewrites 800 specifiers in 247 of the 467 emitted files, and now
completes each with .js; plain node links every compiled module and starts
dist/src/main.js and the worker up to their configuration on Node 24.21
(alias-example.spec.ts, which CI also runs on Node 22). Zero import of a file of the project
ends with .js, .ts or .mjs under src/, test/ and scripts/, of 2101.
Frictions:
- The compiler no longer refuses the old spelling. Under the bundler resolution
.../person.repository.jsstill type-checks. Onlyimports/extension(checker, ESLint) and then the build refuse it, so a project that turns the rule off loses the single spelling. - The compiler no longer models Node for packages. A default import of a CommonJS package
that Node hands over as
{ default }type-checks underBundlerand fails at run time; this application has none, and its specs and the linking of every compiled module would show it. Thenodecondition is given back to the compiler (customConditions), so the entry it reads in a package is the one Node loads. - An import can name a directory for the compiler, not for the build.
.../published-languagewould resolve to itsindex.ts; the build refuses it and the checker reports it. This application always wrote.../published-language/index, so nothing had to change. - The postgres run and the full start with the compose services were not replayed for this
change:
test:postgresandinfra:upneed the services, and nothing they exercise reads an import specifier.
2026-10-12: dependencies stated by the constructor, and projections as a role
Section titled “2026-10-12: dependencies stated by the constructor, and projections as a role”The owner's decision: a handler is written without repeating its dependencies in its
decorator. Before, each of the 89 classes of this application that take something said it
twice, @CommandHandler(RegisterPersonCommand, [PersonRepository, Clock]) and then the
constructor. Now the constructor says it once, and the kit reads it.
Measured first, on the compilers and runners this application meets, with a decorated class
whose constructor takes two ports: design:paramtypes, the metadata emitDecoratorMetadata
writes, is there under tsc 6 and 7, under Vitest 5 and under swc only with legacy
decorators; it is absent under tsx (the dev script) in both decorator modes and with
standard decorators everywhere; and it is Object for a port imported with import type.
This application is on legacy decorators (class-validator), so metadata would have worked in
its build and its tests and failed under npm run dev. The kit reads the signatures from the
sources instead and writes them in the generated barrel of each context.
What was done here, in this order, each step by a tool of the kit:
"dependencies": "signature"understackinddd-ts-kit.config.json.npx eslint . --fix: the new ruledeclarations/constructor-injectionreported 89 lists, all 89 equal to their constructor, none that differed, and removed them. 89 files changed, one line each; nothing else in them: the fixer left the imports as they were, so the ports stay imported as values here, which the generated code of a new project writes asimport type. Both spellings run: the barrel is what imports the value.node scripts/kit.mjs docs: the four barrels now hold 122export *(118 before, plus the four projections) and 89injected(Class, [Tokens])statements with their imports, laid out as Biome lays them out, soformat:checkhas nothing to say of a generated file.- The
devscript, by hand, asinitnow writes it:--import ddd-ts-kit/dist/refresh-declarations.js. - The three
extraDirectoriesentries and their three deviations, removed: the checker knowsapplication/projections/. ADR 0002 is marked superseded and kept. src/platform/compose-application.tsno longer names the four projections: the barrels bring them.
What it changed in the tests:
- Nine handler specs of
schedulingnow give a fake toAgenda. The barrel brings the agenda projection, which applies the domain events a handler announces with its save: with the projection left out, as before, those specs ran a context that was not the one the application runs. - Two specs that built a projection alone take it from the generated file of its context
(
contextModule('analytics', { OutcomesProjection: analyticsDeclarations.OutcomesProjection })): a class imported from its own file has no dependency list. Two others that added a projection by name to the declarations no longer do. - New
test/architecture/every-class-resolves.spec.ts: the composition root asmain.tsbuilds it, on PostgreSQL in process, then every declared class of the four contexts built one by one. A list that is missing, of another length or naming something nobody binds fails there, by name.
Checked after it: npm run verify (types, lint, format, 1298 unit and adapter specs with the
coverage thresholds, 57 end-to-end specs, the checker with 0 finding in 724 files,
dependency-cruiser on 763 modules and 3481 dependencies, knip, docs:check), then every rule
id of the kit broken once in a copy of this application (76 specs, the new
declarations/constructor-injection among them: a constructor parameter typed number).
Frictions:
- A constructor edited by hand leaves the barrel stale until something regenerates it.
checkanddocs --checksay so and name the class,devregenerates on every reload, and the application refuses to start with a list of another length; but a list of the same length and other tokens (two ports swapped for two others) is only caught by the gate and by the type check of the barrel, not at startup. - Every injection of the conformance suite that changes a constructor now also reports
declarations/barrel-current. Eight injections declare it as a companion. It is the honest reading: the generated file is part of what a constructor change touches. - Bringing projections into the barrel changed what a handler spec assembles. It is the right context, and it cost a fake in nine specs. A team that migrates meets the same.
- A reload that rewrote the barrel can be followed by a second one, for the barrel
itself, which the watcher also watches. It finds nothing to rewrite. Not measured here:
the
devproof is on a generated project (dev-reload.spec.tsof the CLI), not on this application, whose start needs its services. - The full start with the compose services and the postgres run were not replayed for
this change:
infra:upandtest:postgresneed the services.
2026-10-12: errors built by a factory of their concept
Section titled “2026-10-12: errors built by a factory of their concept”The owner's decision: the code that refuses reads as a sentence, and the errors of one concept
live together. throw new SlotTakenError(calendar.id, slot.startsAt) became
throw AppointmentError.slotTaken(calendar.id, slot.startsAt).
| Before | After | |
|---|---|---|
error classes (domain/errors/*.error.ts) |
44 files, 400 lines | 8 files, 293 lines |
their specs (test/unit/*.error.spec.ts) |
44 files, 541 lines | 8 files, 458 lines |
| refusals | 44 classes, one code each | 44 factories on 8 classes, the same 44 codes |
| places that build one | 164 new ...Error(..) |
164 factory calls |
| facts a test can read as data | the parameters kept as properties, on 16 classes | every parameter, on every refusal |
| facts a client can read as data | none (the message only) | 11 refusals expose one, by name |
The concepts come from the glossaries, one per aggregate: PracticeError (8 refusals),
DelegationError (4), PersonError (3) in identity; AppointmentError (13), CalendarError
(10), WaitlistError (3) in scheduling; NotificationError (2), ReminderError (1) in
notification. A refusal goes to the concept the caller asked for: the calendar and the
scheduler detect that a slot is held, and refuse with AppointmentError.slotTaken. The
refusals of a value object go to the concept it describes: PersonError.invalidEmail,
CalendarError.invalidRange. No value object has an error class of its own, and none could
be named RangeError.
How it was done, in this order:
ddd-ts-kit error-factories --planproposed a concept for 15 of the 44 classes, the ones named after an aggregate (AppointmentNotFoundError,UnknownCalendarError). The 29 others were given theirs by hand in the plan file: this is the decision of the model.ddd-ts-kit error-factories --applywrote 101 files and removed 88: the 8 error classes, each factory with the parameters and the message of the constructor it replaces and its plain parameters as facts; the 8 specs, the cases of each old spec under the name of its factory; and every usage, among them the assertions on a class, turned into assertions on a code (toThrow(OutsideAvailabilityError)istoThrow(expect.objectContaining({ code: 'OUTSIDE_AVAILABILITY' }))). It set"stack.refusals": "factories". Thennpm run format.- By hand: the facts a client may read (
startsAtof a slot,notice,horizon,grid,limit, thestatusof an ended appointment,held, thedayand theendsAtthat were sent), never an identifier of a person; the 8 specs rewritten as one table per class that asserts the code, the message, the facts and the exposed facts of each factory; one spec that readerror.given, nowexpectRefusal(result, 'INVALID_NOTIFICATION_KIND', { given }).
What the tools said:
- The first run of the codemod sent
UnknownTimeZoneErrorof scheduling toPracticeError, the concept of the class of the same name in identity: a move was applied by class name across the project. The type check caught it in one file. The codemod now applies a move to the files of its own context, and elsewhere to the files that import the class from there; a test holds it. - An assertion written over three lines,
toThrow(\n OutsideAvailabilityError,\n), was first left to the last pass, which turned it intotoThrow(AppointmentError): a weaker assertion, green. The patterns now accept a trailing comma, and a class left as a value is reported. - The HTTP contract did not move:
openapi/openapi.json, regenerated, differs from the previous one by five added lines, the optionalfactsmember of theProblemschema. Every path, status andx-error-codeslist is the same, and the end-to-end specs were not edited. CONTEXT.mdof the three contexts gained anErrorstable: class, refusal, code, exposed facts.
Left as it is, on purpose:
- Ten factories still take a sentence:
CalendarError.invalidRange(rule),AppointmentError.invalidSlot(reason). The message is written by the caller, as it was before. Each is a candidate for one factory per rule, which would change nothing for a client (same code) and is a change of the model, not of the form. UNKNOWN_TIME_ZONEis set in identity and in scheduling. A code is unique in its context; both are answered 422 by the one error mapper.LAST_MANAGERkept its code and its factory isPracticeError.lastManager(): the class wasPracticeLeftUnmanagedError, whose name said something else than its code.
Same day, the ports and kernel types this application only uses as types are imported with
import type (114 import statements in 78 files), as the templates write them. The generated
declarations did not change: the signature reader follows a type import to the class it names,
and the generated file imports the value.
2026-10-12: routes as controller classes, behind route groups and guards
Section titled “2026-10-12: routes as controller classes, behind route groups and guards”The 24 routes of the four contexts and the audit route of the platform became controller
classes, with ddd-ts-kit controllers then by hand for what the command leaves to a person.
| Before | After | |
|---|---|---|
| Form of a route | defineRoute function, listed in <context>.routes.ts |
class carrying @Controller, one method, no list |
Files under presentation/http/ and the audit route |
90, flat (61 in scheduling) |
98: one folder per route, 9 group files, 1 mapper, 2 serializers |
| Lines of those files | 1967 | 1910 |
| Hand-maintained lists of routes | 4, and their 4 specs | 0 |
| Route groups | none | 9: people, practices, practice-opening (identity), appointments, calendars, practices (scheduling), notifications, practices (analytics), audit (platform) |
| Lines of the route or controller specs | 1937 | 2088 |
openapi/openapi.json |
byte for byte the same | |
| End-to-end specs | 57 pass | 57 pass, unedited, span trees included |
What the command did: the 24 controllers, the 8 groups of the contexts (each behind
Authenticated, with FORBIDDEN said once), the 61 schema files moved under their route or
under shared/, every import rewritten, the 24 specs moved, the 4 lists, their specs and
their lines in the composition root removed. What was done by hand: the line that mounts the
declared controllers and the audit controller in compose-application.ts, two integration
specs that named the list of routes, one spec whose call spanned several lines, the audit
controller (outside the contexts), and the config key, which the command leaves while a
function route remains.
Two groups ask for a role at the edge, HasRole('platform-admin'): PracticeOpeningRoutes,
which refines PracticesRoutes of identity, and AuditRoutes. The permissions
identity:practice.open and audit:trail.read are unchanged on their use cases. A caller
without the role is now refused before the bus: the status and the code are the same, and the
audit trail no longer records a denied decision for it, since no decision was asked.
BookAppointmentController has a request mapper, GetCalendarDayController a serializer, and
the two searches of the agenda share AgendaEntrySerializer, which removed a duplicated
function. Six controllers still keep a small helper below their class (grant-delegation,
search-occupancy, reschedule-appointment, search-notifications, join-waitlist,
get-appointment): each is a mapper or a serializer to extract.
No route of the application is public, so Public is not shown here: the kit holds it in its
own specs. PersonError.invalidEmail quotes the address it refuses in its message: the client
now reads Invalid email. as detail, and the spec of the controller holds that the address
does not leave.
2026-10-13: one aggregate kept as its events
Section titled “2026-10-13: one aggregate kept as its events”The kit gained event sourcing, chosen per aggregate. The example shows it on one aggregate whose history is the business, the attendance record of a patient in a practice: the no-shows and the cancellations of the patient, each noted once, in order. Every other aggregate keeps its state in tables, in the same context and the same transaction.
| Event-sourced aggregate | AttendanceRecord (scheduling), three events: created, no-show-noted, cancellation-noted |
| How it is fed | the before-commit handlers of scheduling.appointment.marked-no-show (new) and of scheduling.appointment.cancelled (a cancellation by the patient) send NoteNoShow and NoteCancellation, as the system actor scheduling.note-attendance: the stream is appended in the unit of work of the appointment |
| Store | PostgreSQL through knex, in the scheduling schema (createEventStoreTables in createSchedulingTables), bound by the platform; in memory for the handler specs |
| Snapshots | every 20 events, proven equal to a full replay (expectSnapshotEquivalence) |
| Personal data | the words of a patient who cancels are sealed with the key of the patient; an erased patient reads [erased] after a rebuild |
| Projection | scheduling.attendance-history, fed by the store ({ feed: 'event-store' }): a catch-up subscription in the API process, at the pace of the outbox relay; its rebuild from position zero is proven on PostgreSQL |
| HTTP contract | additive only: GET /practices/{practiceId}/patients/{patientId}/attendance (searchAttendanceHistory), and the permissions scheduling:attendance.note, scheduling:attendance.read (the second asked of a practice, as work) |
| Generator runs | 11, logged in docs/tooling/generator-log.jsonl; 2 refused |
| Stored shapes recorded | node scripts/kit.mjs docs --stored-events: stored-events.lock.json |
What the generators did: generate aggregate --event-sourced wrote the aggregate, its birth
event, the codec of its stored events, a builder that replays a history and a given-events
spec; generate domain-event added each later event to the union, to appliers() with an
empty apply method, and to the codec; the commands, the event handler, the query and the
controller came from their kinds as usual.
Frictions, for the owner of the kit:
scripts/kit.mjsfailed on a fresh checkout:main()ran before the constant it reads when the cache directory does not exist yet. Fixed by declaring the constant first.- The birth event is
<Name>Created: the glossary held "Create" for calendars only, and its meaning was widened to the attendance record. generate projectiontakes an integration event only: the projection of the store, of domain events, was written by hand.generate repositoryhas no event-sourced form: the port, the adapter overEventSourcedRepositoryand the fake were written by hand, about 70 lines.generate querywithout--searchstill wrote a reader port, its in-memory adapter and its fake; the query reads the store of the projection, and the three files were removed.- Three existing specs grant the
systemrole the permissions of the commands the context sends itself: each neededattendance.note, since cancelling or marking an appointment now notes the attendance in the same unit of work. - The integration specs, run in parallel on twenty cores beside other workloads, timed out while starting PGlite (a hook limit of 10 s); they pass serially and with four workers.
2026-10-13, after review: the gates of the stored shapes, an erasure
Section titled “2026-10-13, after review: the gates of the stored shapes, an erasure”A review of the lot found the lock of the stored shapes held by memory alone, and no way to run the erasure the documentation prescribes. What changed in the example:
| Stored shapes | npm run docs:check (ddd-ts-kit docs --check) now fails while a version declared in a codec is not recorded in stored-events.lock.json; the fingerprint of a shape includes the types of the codec it reaches |
| Data subject | the cancellation event carries the patient it seals the reason for (cancelled.patientId), instead of reading it from the identifier of the record; the practice part of that identifier is escaped, so two pairs never share one |
| Erasure | npm run erase:patient -- <patientId>: the key is destroyed, then every projection fed by the store is rebuilt |
| Catch-up | a tick that comes while a round runs is skipped; a checkpoint moved first by another replica is a skipped round, not an error |
Frictions, for the owner of the kit:
- The shape of
scheduling.attendance-record.cancellation-notedchanged in version 1, which was recorded but never stored outside the tests and is not merged: its line was removed from the lock and recorded again, the pathdocs/concepts/event-sourcing.mddescribes. Once merged, the same change is a version 2 with its upcaster. docs --checkreported the unrecorded version at once, with the command to run: the gate did what the review asked of it.- The catch-up and the rebuild of a projection fed by the store are still written by hand in
the platform (
store-fed-projections.ts):generate projectionhas no event-store form.
2026-10-13: documents of appointments, kept in the file storage
Section titled “2026-10-13: documents of appointments, kept in the file storage”A practice attaches a document to an appointment (a referral, a prescription, a report), a patient or the practice fetches it through a link of five minutes, and the practice removes it. The content never reaches the database: the appointment keeps the reference of the stored file (key, size, media type, checksum), its name and when it was attached.
| Config | "storage": "local": a directory in development (.data/files), and in the end-to-end journey and the integration spec of the slice, each in a temporary directory; the memory in the handler specs. MinIO in docker-compose.yml behind the files profile is a recipe for moving to an object store (the comment of the service says the steps), not a storage the example runs on |
| Platform | what init --storage local writes for this stack (platform/files/, platform/config/read-files.ts, the file routes mounted before express.json(), the upload reader of the HTTP boundary, the purge of staged files every hour), applied as the diff between init --storage none and init --storage local of the same stack, since init --force would have rewritten the hand-tuned files of the platform |
| Generators | generate value-object (AttachedDocument), two generate error (noDocument, documentNameTooLong), generate file-upload, generate command, generate query, two generate route, then docs |
| Written or rewritten by hand | the value object, the two refusals, the state and two methods of Appointment, the six columns of the mapper, the migration 20261013090000_add-appointment-documents.js and upgradeTables for the in-process database of the tests, the three handlers, the three controllers, the specs, the end-to-end journey |
| Routes added | POST, GET and DELETE /appointments/{appointmentId}/document; the rest of openapi/openapi.yaml is unchanged (228 lines added, none removed) |
| Audit | scheduling:appointment.read-document is a sensitive permission of auditDecisions: every allowed read of a document is recorded |
| Tests | 23 unit specs of the slice, the round trip of the reference in the contract of both appointment repositories, document-lands-with-its-file.spec.ts on the in-process database and the local storage (the row and the file land together; a commit refused after the file moved returns it to staging/ and leaves the row without a document), and test/e2e/journey-documents.e2e.spec.ts on the local storage: an upload streamed through the multipart reader, the file under its final key on disk, a download through the link, a 415 for a disguised file, a 403 for another patient, the link that stops answering and the file gone once the document is removed |
After the review of the lot, the order of a write changed in the kit and here with it. The
command carries a FileUpload (new FileUpload(file, 'appointment-documents')), which the
command bus stages after the permission and before the audit entry opens the transaction
(platform/files/staging-uploads.ts, as init now writes it): no transaction waits on a
client that uploads slowly. The handler reads command.document.file and calls
promoteOnCommit, which moves the file inside the transaction, as its last step, so a committed
row never points into staging/ and the hourly purge only meets orphans. One behaviour moved:
an upload to an appointment that does not exist is now received, then left in staging/ for
the purge, since the handler that finds the appointment runs after the staging. In production
FILES_PUBLIC_URL is now required, like FILES_SIGNING_SECRET.
What the generators did not fit, to change in the kit:
generate file-uploadwrites a controller without the schema of its path parameters: a document attached to an appointment needsappointmentId, written by hand with its class schema. The command it writes carries the file only; the identifier of the aggregate was added.generate querywrote a reader port, its in-memory adapter and its fake: this query reads the aggregate and signs a link, so the three files were removed.scripts/kit.mjsof this example failed on any generator that creates a file (NOT_A_FILEwas declared after the call that read it); fixed here.- The integration specs of this example time out when vitest runs their files in parallel on a
loaded machine (PGlite per file);
npm run test:postgresruns them one file at a time, and passes.
2026-10-13: refusals at the edge in the audit trail, and the tenancy of the application
Section titled “2026-10-13: refusals at the edge in the audit trail, and the tenancy of the application”Two changes of the kit reached this application.
A caller refused by a guard leaves a denied decision. The previous section noted it: since
PracticeOpeningRoutes and AuditRoutes ask HasRole('platform-admin') at the edge, a caller
without the role was refused before the bus, and the trail no longer recorded anything, since no
decision was asked. The HTTP pipeline now reports every refusal of a guard to onGuardRefusal,
and auditGuardRefusals of @ddd-ts-kit/audit records it: category authorization, action
http:<operationId>, outcome denied, the code of the refusal as reason, and the actor, the
session and the correlation of the request. Three files changed here, as init now writes them:
audit/create-audit.ts (the hook, next to decisions), auth/http-boundary.ts (refusals in,
onGuardRefusal out) and compose-application.ts (the hook built from the bound AuditTrail,
Clock and IdGenerator). identity-open-practice.e2e.spec.ts now holds that the refusal of
a person who is not a platform admin is in the trail, under http:openPractice.
The tenancy of this application is proposed, not yet decided. The kit can now
confine every statement to a tenant ("stack.tenancy": "shared-table", row-level security, a
schema or a database per tenant). The data here is grouped by practice, but a practice is not a
tenant: a patient books in several practices with one account and lists their appointments across
them, and the boundary between practices is the relationship model, decided per element and
narrowing every search. ADR-0003 proposes it; the owner had asked for a tenancy made explicit
and enforced, so the record stays proposed until the owner chooses between it and one network
as a tenant on shared tables. Until then the config says nothing of tenancy, so the new rule
tenancy/scoped-statement has nothing to judge: it is the one entry of NOT_APPLICABLE of the
conformance suite, with that reason, and the kit proves the rule both ways in its own fixtures and
on the shared-table stacks of its end-to-end matrix.
Checked here: the type check of the application and ddd-ts-kit check (0 finding in 657 files).
The end-to-end specs, npm run verify and the conformance run were left to the pipeline of the
pull request, which runs them on a fresh clone.
Frictions:
- A refusal at the edge is recorded outside the unit of work of any command. It is written
when the answer is built, after the request scope closed: the entry carries the execution
context the pipeline hands over, not one the trail reads from the scope. Same facts, another
path; a project that writes its own hook must use
GuardRefusal.execution. - The tenant of the realm token is not the practice. The
tenant_idclaim still names the chain of the audit trail of an actor; a reader who expects it to bound what a user sees will be surprised. ADR-0003 says where the boundary is instead.
2026-10-13 (later): what a review of the tenancy changed here
Section titled “2026-10-13 (later): what a review of the tenancy changed here”A review of the kit's multitenancy found holes the application does not reach, since its config says nothing of tenancy, and two that it does:
- A refusal of a caller without a credential is no longer recorded. The trail of
http:openPracticekeeps the refusal of an authenticated person who is not a platform admin (whatidentity-open-practice.e2e.spec.tsholds); an anonymous request answered 401 writes nothing, so unauthenticated traffic cannot grow the trail. - A guard that fails is not a refusal. A guard of this application that threw something other
than a
DomainErrorwould now answer 500 and leave no denied decision.
ADR-0003 went back to proposed: the owner asked for an enforced tenancy, and the record says the
opposite, so the owner decides. Nothing else changed in the code of the application.
Frictions:
- A tenant id has one grammar now (letters, digits,
_,-, at most 63). Thetenant_idclaim of the realm names a practice by its id, which fits; a realm whose ids held a.would be refused at the first request.
2026-10-13 (evening): one network of practices is one tenant
Section titled “2026-10-13 (evening): one network of practices is one tenant”The owner chose the second alternative of ADR-0003, now accepted: one network of practices is
one tenant, on shared tables, so that every tenancy rule of the kit is applied and tested here.
"stack.tenancy": "shared-table"; every table of the four contexts carries tenant_id (twelve
tables, each with a (tenant_id, key) index); every repository and read model reaches its table
through tenantTable, ownTenantTable or the tenant option of its store; the outbox keeps the
network of each event; the three jobs run once per network of NETWORKS; the read side of the
audit trail is a TenantAuditReader; the edge reads the network from x-tenant-id or the
token. tenancy/scoped-statement judges the adapters with no finding (ddd-ts-kit check: 0 in
660 files), and its entry left NOT_APPLICABLE of the conformance suite for an injection.
HTTP contract changes: POST /practices asks the role network-admin (it asked
platform-admin); GET /audit/entries takes network, and is read by platform staff through an
elevation over that network or every network, recorded first (tenancy:elevate, reason
AUDIT_TRAIL_SEARCH); a request whose x-tenant-id names another network than the caller's is
answered 403, one that names no possible network 404; every request of a user of the realm needs
a tenant_id claim (the network) to reach data.
Measured here: the integration project (26 files, 338 tests, among them tenantIsolationContract
on the eight Knex repositories) and the unit project pass; journey-tenancy.e2e.spec.ts (5
tests) passes; the other end-to-end specs and the conformance run were left to the pipeline.
Frictions, each fixed in the kit rather than worked around here:
- Raw expressions were reported as raw SQL. The occupancy is measured by aggregates in the
select (
count(*) filter (where ...)), and the schema adds an exclusion constraint: the rule reported eachraw. It now reads the literal text of a raw call and reports only SQL that names a statement or a table (or is not a literal). - A select over a grouped query had no helper. The occupancy pages its groups by their
output columns, so it selects from the grouped query:
derivedTableof@ddd-ts-kit/knexdoes it on the scoped query, without the executor in the adapter. - A per-tenant job could not be registered. Neither
kernelModulenor the test application gave the scheduler aTenantDirectory: both taketenantsnow (the test application defaults to the tenant of its execution context). - The migration of an existing database. The first migration builds from the current
schema, so the second must add the column where it is missing and leave it where it is:
addTenantColumnof@ddd-ts-kit/knexdoes both, and gives the rows already there to the network named (NETWORK_OF_EXISTING_ROWS), refusing rows without one. - A journey spec could not be written. The guard of the plugin asked for
generate e2e, which needs one route; a journey crosses several.test/e2e/journey-*.e2e.spec.tsis now written by hand. date_partis notextract. Rewriting the measure without the wordfromchanged a rounding:date_partanswers a double, which PostgreSQL rounds half to even. The cast tonumerickeeps the rounding the contract spec holds.- A spec that publishes an event by hand names no tenant. The publisher of the application
stamps it (
scopedPublisher); a spec that writes the envelope itself spreadstenantIdinto it, or its consumers run in no network and are refused.
2026-10-13 (night): the identity preset, measured against this identity context
Section titled “2026-10-13 (night): the identity preset, measured against this identity context”The kit gained --preset identity, a generic identity context written whole
(Bootstrap with well-designed contexts).
The identity context of this example was compared with it, part by part, to take what improves
it without changing its behaviour. Nothing was taken: each difference is either a model this
example needs or a change of its contract.
| Part | Here | The preset | Taken |
|---|---|---|---|
| Classification | supporting: delegations between a practitioner and an assistant on one desk |
generic |
no: the delegation here is the reason of the context |
| Who | Person, registered at the first sign-in, email sent by the client |
Account, email always read from the token |
no: PUT /people/me would stop reading email from its body, a change of contract |
| Groups | Practice with a time zone, managers, practitioners, assistants |
Organisation with owners, admins, members |
no: the time zone and the three roles are the language of scheduling |
| Invitations | none: a manager adds a known person | issued by token, accepted signed in | no: a new feature, not an alignment |
| Delegations | on a desk, with or without the right to change availability, expiring by a job | rights of a role lent for ninety days | no: another meaning under the same word |
| Identity provider | read only (the token) | IdentityProviderAdmin kept in line at least once by consumers of its facts |
no: nothing here needs the realm to follow |
| Tenancy | the network is the tenant, practices inside it | the organisation is the tenant | no: ADR-0003 chose the network |
What the comparison changed in the kit: the preset first answered FORBIDDEN to every signed-in
person under rbac (no role grants its permissions), which a project of this shape would have
met at its first request. SignedInGrantsAuthorizer of @ddd-ts-kit/auth now grants the
permissions of the context to every signed-in person before the model decides, and the preset
wraps createAuthorizer in it. Measured: five projects generated with the preset (rbac on
Drizzle and Zod, rebac on Knex and class-validator with tenants and Keycloak, abac on Fastify
with tenants, custom on Knex with JWT, rbac on class-validator and Fastify) have no finding,
lint and format clean, type-check, and pass their specs.
The documents of appointments arrived from main at the same time. With the network as tenant,
TenantFileKeys files each document under it (tenants/<network>/appointment-documents/...):
journey-documents.e2e.spec.ts now expects that key, and the integration spec of the documents
runs in a network, as every other spec of a repository here.
The event-sourced attendance record arrived from main last. The event store of the kit keeps no
tenant: its events carry no network and a subscription replays them in none, so the projection
of attendance_history cannot know the network of a row. ADR-0004 keeps the five tables of the
store and that read model outside the networks until the store carries a tenant, under one
suppression of tenancy/scoped-statement; the membership of the practice, asked before every
read and note, still keeps one network out of another's records. This is the gap of the kit
that the next change of the event store should close.
2026-10-12: value objects that throw their refusals
Section titled “2026-10-12: value objects that throw their refusals”The value objects of the four contexts moved from a Result to factories that return the value
or throw, with ddd-ts-kit value-objects alone: "stack.valueObjects": "throwing" is set, and
nothing was left to a person.
| Before | After | |
|---|---|---|
Methods returning a Result |
18 (17 create, Booking#movedTo) |
0 |
return err(..) and ok(..)/err(..) ternaries |
25 and 3 | 28 throw, 3 of them after an if |
unwrap(..) of a value object |
168, in handlers, aggregates, a domain service, mappers, builders and specs | 0 |
Assertions on a Result in the specs |
31 (.ok, toMatchObject({ ok: false, error }), expectRefusal(result, ..)) |
expectRefusal(() => .., code) and expect(() => ..).not.toThrow() |
| Files | 82 sources and the config, 389 lines added, 498 removed |
The HTTP contract did not move: npm run docs:generate rewrote openapi/openapi.yaml
byte for byte, since a request is still validated at the edge by its schema before any value
object is built, and a refusal of a factory still reaches the error mapper as the same
ConceptError with the same code. The 1004 unit tests pass unchanged in number.
2026-10-12: closed vocabularies instead of magic strings, and numbers with a name
Section titled “2026-10-12: closed vocabularies instead of magic strings, and numbers with a name”The statuses, kinds and roles of the four contexts became closed vocabularies, each declared once
by ddd-ts-kit generate vocabulary (an object as const and the union type of its name, under
domain/vocabularies/), and the permission ids and realm roles of the platform are read from
their declarations. language/declared-vocabulary listed every raw value before the change;
none is left.
| Vocabulary | Context | Raw values replaced |
|---|---|---|
AppointmentStatus |
scheduling | 71 |
Weekday |
scheduling | 36 |
OutcomeState |
analytics | 27 |
NotificationKindName |
notification | 20 |
RoleName (the role of a member of a practice) |
identity | 15 |
ReminderStatus |
notification | 14 |
DelegationEndCause |
identity | 9 |
Role (the realm roles a token carries) |
platform | 5 |
permission ids, through the permission set of each context (schedulingPermissions['appointment.book'].id) |
platform | 30 |
| Total | 227, in 37 files |
The union types written next to the values ('requested' | 'booked' | ... in the aggregate, the
port of the agenda, the row schema, the DTOs) became the type of the vocabulary; an @IsIn([...])
of a DTO reads Object.values(AppointmentStatus), in the same order, so the OpenAPI contract is
regenerated byte for byte. The two-word list of who cancelled, 'patient' | 'practice', stays a
union of literal types: both words also name a type of resource, a relation of the access model
and a field of a read model, which a vocabulary would report. The published languages keep their
literal values: a contract names them itself.
clean/no-magic-number reported 8 numbers in domain/ and application/, all in two value
objects: the minutes of an hour and the two digits of HH:MM in Range, the minutes of an hour
and the four digits of a year in TimeZone. Each is now a constant of its module
(MINUTES_PER_HOUR, TWO_DIGITS, YEAR_DIGITS).
2026-10-13: event names declared once, and the inventory of closed values
Section titled “2026-10-13: event names declared once, and the inventory of closed values”Each of the 22 domain events now declares its name once, static readonly eventName = 'scheduling.appointment.expired', and carries it in readonly name = AppointmentExpired.eventName.
The 33 reactions that repeated it (26 event handlers and publishers, 7 methods of the agenda
projection) read it, @DomainEventHandler(AppointmentExpired, { name: AppointmentExpired.eventName })
and @On(AppointmentExpired, { name: AppointmentExpired.eventName }), and the 5
integration event handlers of notification read the type they handle from the declaration of
scheduling, static readonly eventType = appointmentExpiredV1.type. The event catalog script
resolves that reference through the names the published languages export: docs:generate wrote
event-catalog.md, asyncapi.yaml and openapi.yaml byte for byte as before. The specs keep the
names written out: a test spells what it expects.
Before the change scheduling.appointment.expired was written six times in the sources: the
domain event, the published-language declaration, the event handler, the publisher, the agenda
projection and the consumer in notification. It is now written twice, once per language: the domain event of
scheduling and its published contract, which names its own values.
What else is a closed list of values in the example, with what holds it:
| Category | Count | Declared in | Read as | State |
|---|---|---|---|---|
| Statuses and kinds of the model | 7 vocabularies, 33 values | domain/vocabularies/*.vocabulary.ts |
AppointmentStatus.NoShow |
typed, language/declared-vocabulary |
| Realm roles | 1 vocabulary, 3 values | src/platform/auth/role.ts |
Role.Admin |
typed, same rule |
| Permission ids | 31 | definePermissions of each context |
schedulingPermissions['appointment.book'] |
typed, same rule |
| Domain event names | 22 | static readonly eventName of the event class |
AppointmentExpired.eventName, 33 reactions |
typed, events/name-format, declarations/role-declared |
| Integration event types | 14 declarations | type of the published-language declaration |
appointmentExpiredV1.type, 5 handlers |
typed |
| Error codes | 43 | the factory of the concept | errors: [..] of a route |
literal, each checked against a factory by errors/known-code |
| Job names | 3 | @Scheduled('notification.send-due-reminders') |
repeated once in systemActor(..) of the job |
literal twice per job, held equal by scheduling/job-name |
| Consumer names | 5 | { consumer: '..' } of @IntegrationEventHandler |
repeated once in systemActor(..) of the handler |
literal twice per handler |
| Other system actors | 5 | systemActor('..') of a domain event handler |
once | literal, single site |
| Projection and policy names | 4 and 2 | @Projection('..'), @Policy(.., { name }) |
once | literal, single site |
| Route group tags | 5 tags on 8 root groups | tags: ['scheduling'] |
the name of the context | literal, scheduling 3 times and identity twice |
| ReBAC resource types | 8 | defineSchema of the platform |
{ type: 'calendar', id } in @Requires, 31 places |
literal, not judged |
| ReBAC relations | 13 | defineSchema of the platform |
relation: 'patient' in 2 repositories, 9 places |
literal, not judged |
| Cache keys | 0 | none | the cache is bound in the platform; no context reads or writes a key | nothing to declare |
Not done, and why. Job and consumer names: the decorator of a class cannot read a static of that
class under standard decorators (its arguments are evaluated before the class exists), and
scheduling/job-name, the generated lists and the docs read the literal of the decorator; the
second copy, in systemActor(..), stays. Route group tags are the name of the context and are
not compared anywhere. ReBAC resource types and relations are declared by the relationship schema
of the platform, which the application layer may not import; a typed form needs a vocabulary the
contexts can read (the shared kernel, or one per context), not decided in this change.
The enum measurement now covers each compiler a generated project runs, on Node 24.21, with a
string enum and the object as const of the same values, each file compiled and run:
| Compiler | Where a generated project runs it | enum |
as const |
|---|---|---|---|
tsc 6.0.3 |
the build and the type check (a Nest project too) | accepted | accepted |
tsc under erasableSyntaxOnly |
a project that opts in | refused, TS1294 | accepted |
| Node's type stripping | a .ts file run by node |
refused, ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX |
runs |
| tsx 4.23.15 (esbuild 0.28.2) | npm run dev |
runs | runs |
| Oxc, through rolldown 1.2.13 | Vitest 5 (Vite 8.3.4) | runs | runs |
| SWC 1.16.13 | Jest, through @swc/jest |
runs | runs |
SWC was measured with its WebAssembly build of the same version: the native one refused to load on
this machine (ERR_SWC_NATIVE_CACHE, a permission check of its cache directory), which says
nothing of the syntax. The transpilers accept both; what refuses the enum is the erasable
subset, which Node runs and tsc can enforce. The object as const runs on every one of them,
so the decision stands.
2026-10-12: a typed client of the contract
Section titled “2026-10-12: a typed client of the contract”node scripts/kit.mjs sdk generated clients/api, the package scheduling-api-client, from
openapi/openapi.yaml, and wired the example: "sdk" in the config, the workspace, the paths
entry of the package in tsconfig.json, knip with the root as a workspace and the clients left
out, and the scripts (docs:generate and docs:check end with the client, ci:build builds
it). The scripts that call the CLI go through scripts/kit.mjs here, as the others do; the
command leaves a script that already runs it.
| Generated | 6 sources, 1 748 lines, for 25 operations and 39 error codes; a README of 100 lines; package.json and two tsconfig files |
Time of sdk --check |
0.18 s, the contract read back from YAML, no build |
| Specs moved to the client | the specs of registering a person and of opening a practice, and registerPeople and openStaffedPractice, which four spec files set their people and practice up with: 131 lines added, 97 removed |
| New helpers | expectAnswer and expectRefusal, typed by the outcome: a status or a code the operation does not declare does not compile ('SLOT_TAKEN' for the 404 of openPractice is refused, 418 too) |
| End-to-end specs run after the change | the two that changed, the three journeys and the spec of adding a member, 36 tests, pass in process |
| Left on the HTTP helpers | VALIDATION_FAILED: a body the contract does not describe cannot be written with the client, which is the point; the steps that wait for another context (eventually) |
Frictions met while wiring it, each fixed in the kit before this commit: Biome lays out a
package.json as npm does (every list expanded), so the command writes it that way; knip, once
package.json declares workspaces, asks for the root to be a workspace of its own, so the command
moves what knip.json said of the project under workspaces["."]; imports/alias-declared
reported sdk:build as a compile without build-aliases, which a client does not need (its
sources import relatively), so the rule leaves the build of clients/; and the CLAUDE.md of the
project said never to add a paths entry, so it now says that the command writes the one of
each client.
The three generators the kit did not take were measured on this contract
(docs/tools/client-sdk.md): none types problem.code by the codes the contract lists, so
expectRefusal could not have been written on their output.
2026-10-13: the typed client after its review
Section titled “2026-10-13: the typed client after its review”A review of the client found what the first pass had not met on this example. Each fix was made
in the kit, then npm run docs:generate rewrote clients/api:
| A 500 of the application | thrown as UnexpectedResponseError with the raw body; it now reads the problem document, code INTERNAL_ERROR and requestId, the reference a caller quotes |
operations.ts |
each operation holds its own description inside its function: no module-level constant, so no two operation ids can clash (listItems and list_items once both gave LIST_ITEMS) |
apiAs(actor) |
gave the token as a raw authorization header; it now gives it as the bearer credential, a function asked before each request, so the specs prove the authentication of the client against the real server, and it no longer needs await |
| Lockfile | none in this directory, which is a workspace of the kit; a generated npm project is told to run npm install when its lock misses a client |
The two changed spec files and the three journeys pass again in process (30 tests).
2026-10-13: shapes declared once, and four contract pairs
Section titled “2026-10-13: shapes declared once, and four contract pairs”The example now says "stack.schemas": "shared". The checker, run with the new rule
schema/no-duplicate-shape on the tree as it was, reported 17 findings, all of one shape: the
eighteen <use-case>.accepted.ts classes, each { @IsString() id }. The two agenda searches
shared one line class through extends, which the rule does not count but the contract
documented twice. What changed, all by hand but the schema file (generate schema scheduling AgendaLine) and the regenerated documents:
| Before | After | |
|---|---|---|
Schema files under presentation/ |
96 | 77 (18 *.accepted.ts and 2 *.line.ts removed, shared/agenda-line.schema.ts added) |
| Identifier answers | 18 classes, 18 copies in the contract | acceptedSchema of @ddd-ts-kit/http, one Accepted component |
| Lines of an agenda search | 2 classes, 2 copies of the page in the contract | AgendaLine (classSchema(AgendaLine, { shared: true })), one AgendaLinePage |
openapi/openapi.yaml |
2781 lines, 90,367 bytes, 112 $ref |
2690 lines, 87,262 bytes, 133 $ref |
components.schemas |
7 | 10: Accepted, AgendaLine, AgendaLinePage added |
Findings of check |
0 | 0 |
The bytes on the wire did not change. The contract did where sharing is the point: the answer
of the 18 command routes is a reference to Accepted ({ id }, now described and closed with
additionalProperties: false), and the two searches answer a reference to AgendaLinePage.
The typed client of the example, written again by ddd-ts-kit sdk from that contract, names
each of them once: Accepted, AgendaLine and AgendaLinePage in clients/api/src/schemas.ts.
Two things met on the way. scripts/kit.mjs declared a constant below the call of its main
function: any generate that created a file failed before running the CLI; the constant now
comes first. And the rule caught a mistake in its own end-to-end spec, where two routes had been
given the same body: the spec now nests the shared shape under a field of its own in each.
The four contract pairs (Express and zod, Fastify and TypeBox, Hono on Node and zod, NestJS and
class-validator) were each generated with two routes sharing one shape, and held to zero
finding, their specs, their end-to-end specs and a current contract; the numbers and the
decision table are in docs/tools/contract-pairs.md of the kit.
2026-10-13: the shapes after their review, and a part of a shape reused
Section titled “2026-10-13: the shapes after their review, and a part of a shape reused”The review of the shared schemas asked for five things the example shows or is held by:
| Before | After | |
|---|---|---|
| Identifier answers | 18 Accepted; the two document routes merged from main answered their own *.accepted.ts |
20 routes on acceptedSchema, the two *.accepted.ts removed |
startsAt, ISO 8601 with its zone |
declared by hand in BookAppointmentBody, RescheduleAppointmentBody and AddTimeOffBody |
declared once in shared/time-span.schema.ts (generate schema scheduling TimeSpan): the time off route answers to TimeSpan whole, the two appointment bodies extend pickClass(TimeSpan, ['startsAt']) |
openapi/openapi.yaml (with the three document routes of main) |
2908 lines, 149 $ref |
2910 lines, 150 $ref |
components.schemas |
10 | 11: TimeSpan added |
Findings of check |
0 | 0 |
The bytes on the wire did not change: POST /calendars/{calendarId}/time-off takes the same two
instants, now a reference to TimeSpan; the bodies of booking and rescheduling are documented
as before, patientId now listed before startsAt, because a derived class declares the
picked property first. The rule schema/no-duplicate-shape now judges a context and the shared
kernel only, not two contexts, and leaves alone a shape that still carries a TODO(domain) of
the generator: two searches generated in a row no longer report each other's { id } line.
shared/ still holds the *.dto.ts parts of the calendar answers next to the new *.schema.ts
files: those classes are nested parts of one answer each, documented as components by
class-validator, and renaming them would change their component names in the contract for no
reader's gain.
2026-10-13: HTTP caching, Varnish in front of the API
Section titled “2026-10-13: HTTP caching, Varnish in front of the API”The application gained a shared cache in front of its API, the first public route, a private
read revalidated with an ETag, and a write conditioned by If-Match.
| Config | "httpCache": "varnish" in ddd-ts-kit.config.json |
| Generator runs | 4: generate query scheduling GetOpeningHours --public, generate controller ... --cache public, generate event-handler scheduling Calendar OpeningHoursSet, generate e2e scheduling GetOpeningHours |
| New route | GET /calendars/:calendarId/opening-hours, public: public, max-age=60, s-maxage=3600, stale-while-revalidate=30, stale-if-error=86400, a weak ETag from the version of the calendar, tagged calendar:<id> |
| Changed routes | GET /calendars/:calendarId/days/:day: private, max-age=0, must-revalidate and a strong ETag; PUT /calendars/:calendarId/opening-hours: If-Match optional, 412 PRECONDITION_FAILED on a stale version |
| Every other authenticated route | answers Cache-Control: no-store (refusals included), documented in the contract |
| Purge | CalendarOpeningHoursSetEventHandler, before the commit, asks CacheInvalidation for calendar:<id>; the relay of main.ts publishes through PurgingPublisher and VarnishCachePurger |
| Infrastructure | a varnish service (Varnish 9.1.0) in docker-compose.yml, its VCL in docker/varnish/default.vcl (the one init --http-cache varnish writes, backend port 54300), VARNISH_URL and VARNISH_PORT in .env.example |
| End to end | test/e2e/get-opening-hours.e2e.spec.ts: five specs in process (headers, 304, a purge after the committed command and none for the refused one, 412, 404 kept by nobody), a sixth through Varnish when VARNISH_URL is set (a HIT, then the new hours after the purge); CI starts Varnish for it |
What the generators left by hand: the query generator wrote a reader port, its in-memory
adapter and its fake, removed since the hours are read from the CalendarRepository (a getter
openingHours on the aggregate); the generated controller of a public query takes no request
and builds its query without arguments, filled by hand; the event handler and the e2e spec are
filled from their markers. The platform files (main.ts, the HTTP boundary, the configuration,
the compose file) were written by hand after the templates init --http-cache varnish uses, since
init is not run again on this application.
One defect of the example's own tooling was found and fixed: scripts/kit.mjs read
NOT_A_FILE before its declaration whenever a generate was about to create a file that did not
exist yet, so every generator run that created a file crashed with a ReferenceError (the
declaration moved above main()).
2026-10-13 (later): what a review of the HTTP caching changed here
Section titled “2026-10-13 (later): what a review of the HTTP caching changed here”A review found the cached hours stale after three of the four changes of a calendar, and the generated VCL keeping answers no route allowed.
| Generator runs | 3: generate event-handler scheduling Calendar ExceptionAdded, ... TimeOffAdded, ... AppointmentTypeDefined, filled from their markers |
| Purge | every event that changes the version of a calendar now purges calendar:<id>: before, only CalendarOpeningHoursSet did, and after an exception, a time off or an appointment type Varnish served for up to an hour a version every If-Match built on was refused with 412 |
| Relay | main.ts purges through RepeatedCachePurger (two seconds apart, PURGE_AGAIN_AFTER_MS), for a read that loaded the hours before the commit and reached Varnish after the first purge |
| VCL | docker/varnish/default.vcl, written again from the template: an answer without max-age or s-maxage is never kept (the 404 of an unknown calendar, a probe), a purge comes from the machine of Varnish or carries VARNISH_PURGE_KEY, a refused ban answers 500 |
| Configuration | VARNISH_PURGE_KEY in .env.example, in the environment of the varnish service and in loadConfig (varnishPurgeKey, sent by VarnishCachePurger) |
| End to end | get-opening-hours.e2e.spec.ts: a change of the calendar other than its hours purges them and a write made on the version read afterwards succeeds, in process and through Varnish (8 specs, passed through Varnish 9.1.0 on this machine); the setup waits for the two purges of the practice it opens |
Contract: unchanged.
2026-10-13: background jobs, a state machine and a process manager
Section titled “2026-10-13: background jobs, a state machine and a process manager”What this example gained, and how much of it a generator wrote:
| The lifecycle of an appointment | domain/state-machines/appointment.state-machine.ts, written by hand before generate state-machine existed; the aggregate now asks it every status, and CONTEXT.md draws it |
| The confirmation deadline | a process manager (application/processes/confirmation-deadline.process.ts), four event handlers and the background job of its timeouts, written by hand before generate process existed; its shape is the one the generator now writes |
| The clearance of a calendar | generate command (ClearCalendar), generate query (GetClearance) and generate route twice wrote the skeletons; the background job was written by hand |
| Stores | the jobs and the process instances in the messaging schema of PostgreSQL (KnexJobStore, KnexProcessStore), recorded as "queue": "database" |
Frictions met on the way:
generate background-job ... --command CancelAppointmentrefuses the command: its constructor needsappointmentId, which a generated job cannot know. Right for a generated job, which sends one command from its actor; the clearance sends one command per element of its payload, a loop with a checkpoint that no template guesses. Written by hand, 70 lines.generate query GetClearancewrote a reader port, its in-memory adapter and its fake: the answer comes fromJobQueue.find, a port of the kernel, so the three files were deleted.- In a spec, a command executed with an actor that is not the one of the execution scope
enqueues its job as the scope's actor (
test-user), not as the actor of the command: the job was then refusedappointment.cancel.application.as(holder, ...)in the spec, as the HTTP boundary does, fixed it; the specgenerate background-jobwrites enqueues the same way. - The first status list of the answer of
GetClearancewrote'cancelled'as a literal:language/declared-vocabularyreported it as a value ofAppointmentStatus. Since the review below, the kit publishesQueuedJobStatusas a vocabulary and the contract has an enum.
Proven: 16 unit tests for the clearance (the command, the job interrupted half-way and resumed,
an appointment cancelled meanwhile, the query, both controllers), and an end-to-end journey of
two tests on the application with its worker running, on PostgreSQL in process: the time off is
refused, the clearance cancels both appointments and says { done: 2, total: 2 }, then the
time off is accepted. The unit suite (1 340 tests) and its coverage floors pass.
2026-10-13, after review: a clearance belongs to its calendar
Section titled “2026-10-13, after review: a clearance belongs to its calendar”A review of the lot found the query of a clearance trusting the secrecy of its identifier, its status published as free text, and the queue blind to tenants. What changed here:
| Ownership | ClearCalendar enqueues its job with the calendar as its subject; GetClearance answers UNKNOWN_CLEARANCE (404) for a job whose subject is another calendar, and the queue hides the jobs of another tenant from find |
| Contract | GET /calendars/{calendarId}/clearances/{clearanceId}: status is now an enum (waiting, running, succeeded, cancelled, dead) through @IsIn(Object.values(QueuedJobStatus)); the typed client narrows it the same way. No route was added or removed |
| Proven | a unit case (the clearance of another calendar is unknown) and an end-to-end case (the practitioner of another practice gets 404 for the clearance of this one) |
| Known gap | the clearance fixes its list when it is asked and does not reserve the period: an appointment booked before the time off is added is neither cancelled nor blocked, and the time off is refused again; a retried request starts a second clearance. Written in the handler, left as is |
generate background-job now carries the inputs of a command in the payload (a Date as its
ISO text): --command CancelAppointment would generate today, but the clearance still sends one
command per appointment of its payload, with a checkpoint between two, which no template guesses.
2026-10-13: media types, fields reserved to the staff, and a form described by the server
Section titled “2026-10-13: media types, fields reserved to the staff, and a form described by the server”The config says "representations": "negotiated" and "forms": "on", and the platform has the
two files init now writes for them (http/presentation-of.ts, http/with-forms.ts), with
the boundary, the composition root and scripts/openapi.ts changed as the templates are.
| Before | After | |
|---|---|---|
| Media types a route answers in | JSON | JSON, and what it declares: application/xml and text/csv for the agenda of a practice and its export, application/xml for a booking |
| Bodies read | JSON | JSON, and XML on POST /appointments |
| Export of an agenda | none | GET /practices/{practiceId}/agenda/export, streamed page after page of the read model (CSV or XML) |
| Internal note | none | written at booking by whoever runs the calendar, read by its staff, refused (403) from and hidden from its patient: withFieldAccess on the body and on the detail, Access.authorize in the handler for every other caller |
| Forms | none | GET /forms/bookAppointment (JSON Forms, or ?format=blocks), openapi/forms/bookAppointment.json generated |
| End-to-end specs | book-appointment.e2e.spec.ts (XML booking, the form for the patient, the assistant and the practitioner, the note), export-practice-agenda.e2e.spec.ts (CSV, XML, 406) |
What the generators wrote: the domain event AppointmentAnnotated (with its glossary term),
the query and the controller of the export, the two end-to-end specs, the produces and
consumes of three controllers (generate representation) and the hints of the booking form
(generate form), which were then filled. What was done by hand: the note in the aggregate,
its row, its column and the agenda, the projector of the annotation, the field rules, the
permissions appointment.note.read and appointment.note.write with the relationship staff
of a calendar (whoever works in its practice), and the export handler, which reads the agenda
by cursor pages instead of the reader port the query generator proposes.
Frictions met, fixed in the kit in the same change:
- The JSON Schema of a class-validator class that only extends another one was an empty object:
the agenda lines of the contract said nothing of their fields, and a CSV of them had no
column.
@ddd-ts-kit/validationnow gives a class the properties of its ancestors; the contract of the agenda lists them. - A field decision ran outside the execution scope of the request, where the audit of the decisions requires it: the pipeline now decides fields, and reads a streamed list, inside it.
- The form generator imported
Inferas a value in a class-validator controller.
HTTP contract changes: the media types above under each route concerned, 406 with
NOT_ACCEPTABLE on the routes that produce more than JSON, 415 with
UNSUPPORTED_MEDIA_TYPE on the booking, internalNote (optional, marked x-field-access) in
the body of POST /appointments and in the answer of GET /appointments/{appointmentId}, the
new routes GET /practices/{practiceId}/agenda/export and GET /forms/bookAppointment, and
the properties of the agenda lines, which were missing.
The internal note is a migration of its own, 20261013110000_add-internal-notes.js, after the
one of the documents: the tables are shared since then, and upgrade-tables.ts applies the same
change to the in-process database of the tests.
2026-10-13 (later): what a review of media types, field access and forms changed here
Section titled “2026-10-13 (later): what a review of media types, field access and forms changed here”| Before | After | |
|---|---|---|
Downloads of documents (/files?token=) |
under negotiation, a file went out as a JSON array of bytes, and Accept: application/pdf was refused 406: journey-documents.e2e.spec.ts failed |
a route that declares no body schema is left out of negotiation; the file goes out as it was stored, and the journey passes again |
| Export of an agenda | the query ExportPracticeAgenda answered a lazy list, read after the bus had returned: no unit of work, no tenant session, no span for its pages |
the controller streams everyPage over SearchPracticeAgendaQuery, one query per page through the bus; the export query, its handler and its spec are removed |
| A failure half way through an export | the connection was cut, nobody was told | onError is told, with the correlation and interrupted: true |
| The booking form | given to any caller who presented a credential | requires the permission appointment.book, asked as the command asks it (the calendar of the query, selfService when the patient is the caller): a patient asking it for somebody else gets 403, and so does an assistant without a delegation of the desk, who may not book |
| Who writes the note | whoever runs the calendar (run), so an assistant with a delegation would have edited it |
the holder of the desk or the manager of the practice (own, a new permission of the calendar in the relationship schema): an assistant who books on a delegated desk sees the note read-only, which is what the end-to-end spec of the form now shows after a delegation |
| A note met before its booking | the agenda lost it when the two facts of the same instant came in that order | AppointmentAnnotated carries the booking it leaves, as the other facts do: the agenda writes the whole entry with the note |
| Field decisions | one recorded decision per field shown or hidden | recorded only when a field is set; a shown or hidden field only shapes the answer |
HTTP contract changes: 406 NOT_ACCEPTABLE on every route that answers a body, 415 UNSUPPORTED_MEDIA_TYPE on every route that reads one (the contract now follows the
representations the server is mounted with, and the documentation page too), FORBIDDEN on
GET /forms/bookAppointment. The typed client gains the same failures.
2026-10-13: the media types meet the shared shapes, the cache and the jobs
Section titled “2026-10-13: the media types meet the shared shapes, the cache and the jobs”The two lots were merged into one tree. What the merge changed here, beyond taking both sides:
| Export of an agenda | its own line class extended a line class the shared shapes removed: the export now answers AgendaLine (classSchema(AgendaLine, { shared: true })), the component the two searches already point at |
| Booking body | startsAt picked from TimeSpan, the internal note and its field rules kept: the JSON Schema of a class that extends a picked class holds the properties of both |
| Internal note | annotate asked the aggregate a guard the state machine replaced: it is now a transition of appointmentLifecycle (annotate, from requested or booked, the status unchanged) |
| Contract | the media types, 406 and 415 of the representations next to the Cache-Control, ETag, If-Match and 304 of the caching; Vary documents accept on the routes that answer in several media types |
Frictions met in the kit, fixed in the same change: the cache tags, the weak ETag and
Last-Modified were read from the bytes of an XML answer, where no field is found; Vary: accept
replaced the Vary of a cache policy instead of joining it; a public answer chosen by Accept
was accepted behind a cache that ignores Vary; Hono neither read an XML body nor streamed a
list; and withFieldAccess over a shared schema left the contract without its x-field-access.
2026-10-13 (late night): what a review of the tenancy and of the identity preset changed here
Section titled “2026-10-13 (late night): what a review of the tenancy and of the identity preset changed here”The pipeline of the pull request was red on this example, and the review found why:
| Found | Changed |
|---|---|
journey-tenancy failed 5 times out of 5 (422 UNKNOWN_PRACTITIONER): inNetwork gave its network to as only, so the practice of south was opened by the typed client in north |
inNetwork gives the network to every credential it builds (as, headersOf, apiAs); the journey passes, 7 tests |
journey-observability expected the error report of before the networks |
the report names the network (tenantId: 'north'), which is itself a proof |
The conformance suite excused tenancy/scoped-statement ("the example says nothing of tenancy"), and its injected migration took the number of the real one |
the excuse is gone, the injection alone breaks the rule; the injected migration of persistence/no-file-content is numbered after every real one |
A token naming a network missing from NETWORKS reached data that no job would ever process |
servedTenantOf answers 404 at the edge, and a spec proves it |
| A subject registered in two networks collided on a key unique across networks | ADR-0003 holds a person to one network (one tenant_id per user of the realm); the second registration is refused and the first kept, as a spec proves |
| Events in the outbox at the time of the migration had no network | the migration gives the unpublished ones NETWORK_OF_EXISTING_ROWS (tenantOfUnpublished) |
The checker now judges raw SQL by what may reach rows, and refuses a template that interpolates:
two raw aggregates of the occupancy reader bound their list of states as one value
(state = any(?)) instead of interpolated placeholders, and the constraint of the schema binds
its name (??) instead of interpolating it. check reports 0 errors and 1 suppression
(ADR-0004).
The identity context of this example was not changed by the corrections of the preset: it has no organisation of its own, its roles are those of a practice, and its people register themselves. What the preset now does (delegations within the role of the delegate, the provider kept in line at least once, organisations read as tenants from the token) stays a comparison of the section above, not a change of this contract.
The Keycloak adapter of the preset was run once by hand against Keycloak 26.8.0 (the image of
--authn keycloak, in a container removed afterwards): open an organization twice, add, remove
and add back a member, each twice, disable a user twice. The first run failed: the adapter looked
an organization up with search=<alias>&exact=true, which Keycloak matches against the name.
It now reads the pages of the organizations until the alias is found; the second run passed every
step, and the disabled user kept their name and address.
2026-10-13 (merge): the networks meet the jobs, the processes, the cache and the representations
Section titled “2026-10-13 (merge): the networks meet the jobs, the processes, the cache and the representations”Merging 0.11 to 0.14 into the multi-tenant example brought tables and routes written before the networks. What each became:
| Added by | Became |
|---|---|
Background jobs (messaging.background_jobs) |
already kept tenant_id and ran in the network of the work that enqueued them; a clearance is read through the queue, which shows a job to its network only. The migration of ADR-0003 gives NETWORK_OF_EXISTING_ROWS to the jobs waiting or running, in the column and in who enqueued them |
Process instances (messaging.process_instances) |
tenant_id, required: createProcessTable(knex, { tenant: true }), and KnexProcessStore with tenant, bound per execution scope; an instance of another network is neither found nor changed. The migration adds the column to a table created before, its rows given the network named |
| Opening hours, Varnish | tags filed under the network of the request (cacheTags.perTenant), purges under the network of the work (PublishedCacheInvalidation with perTenant), Vary: x-tenant-id; an anonymous caller names the network in x-tenant-id. The tags read north/calendar:<id> |
| Representations, forms, field access, exports | unchanged: they read through the repositories and read models already confined, and the resolver of the network is the one of every route |
| Kit | servedTenantOf dropped the headers of the resolver it holds: the answers of the example did not vary on x-tenant-id, and Varnish would have handed the hours one network read to every other. It now keeps them, and a spec says so |
check: 0 errors, 1 suppression (ADR-0004). get-opening-hours.e2e.spec.ts gained two specs (the
header in Vary, 404 to the calendar of another network) and one through Varnish (a second
network is never served the copy of the first): 11 specs, passed through Varnish 9.1.0 on this
machine. Contract: unchanged, but an anonymous read of the hours names its network.
2026-10-13 (late): names of the business, never target
Section titled “2026-10-13 (late): names of the business, never target”The command handler templates named the aggregate they load or build target, and the handlers
of this example kept it: naming/business-name (ddd-ts-kit check and
ddd-ts-kit/naming-business-name) reported 27 bindings, all named target, in 27 handlers of
three contexts. npx eslint src --fix renamed 23 of them with every use, reading the concept from
the initializer: 14 read from a repository (const practice = await this.practiceRepository.findById(..),
by the type of the call), 4 built (const calendar = Calendar.create(..)), 5 loaded or built
((await this.waitlistRepository.findById(..)) ?? Waitlist.create(..)). The 4 left were renamed by hand from the message: 3 loop variables (for (const reminder of due), delegation, appointment) and the appointment a domain service books
(const appointment = this.scheduler.book(..)).
| Bindings | Occurrences | By | |
|---|---|---|---|
target in application/ (handlers of identity, notification, scheduling) |
27 | 118 | 23 eslint --fix, 4 by hand |
item in presentation/ (a serializer and two controllers), items in an adapter |
4 | 43 | by hand (not judged: named after entry, appointment, notification) |
| Total | 31 | 161 | 31 files |
npm run docs:generate rewrote openapi/, clients/ and docs/integration/ byte for byte: a
local name is not part of the contract. check: 0 errors. The 1145 unit tests pass unchanged in
number.