domain-toolkit
v0.2.0
Published
A small, dependency-free toolkit of TypeScript building blocks for domain-driven design
Readme
domain-toolkit
The published package: TypeScript building blocks for domain-driven design.
For installation and the one compiler option @Handle needs, see the
repo-root README. For every exported signature, see the
API reference, regenerated
from these sources on each push to main.
The building blocks
| Export | What it is |
| ---------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| Entity | A domain object defined by who it is, not what it holds. Records events; never publishes them. |
| AggregateRoot | An Entity that is also a consistency boundary — the only way in, and the unit of persistence and publication. |
| DomainIdGenerator | The port through which the domain obtains a new id. The adapter decides how; the model never looks inside one. |
| ValueObject | Defined entirely by its attributes. No identity, no lifecycle, frozen on construction. |
| DomainEvent | Something that has already happened, in the ubiquitous language. Past tense, immutable, carries ids. |
| @Handle / HandleRegistry | Binds an event to the aggregate method that reacts to it, keyed by eventName. |
| Repository | A collection-like interface over whole aggregates — one per root, never per entity. |
| DomainError and friends | Errors the business refuses, as distinct from bugs. |
| State, RequiredKeys, RequiredState | Re-exported from @domain-toolkit/state, which is bundled into this package's dist. |
Design notes
Each source file keeps the contract in its doc comments — what the thing is and how to call it, which is what the API reference renders. The reasoning behind the implementation lives here, one file per source file:
| Notes | Covers |
| -------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| docs/entity.md | Why an id is a string and where it comes from; why an entity holds a state manager instead of extending one; the machinery behind create; why mutate wraps the operation. |
| docs/aggregate-root.md | The cycle guard; why hasPendingEvents is overridden at all; fromEvents and the abstractness check that was traded away. |
| docs/value-object.md | Why the freeze is enforced rather than recommended, and why equals is deliberately shallow. |
| docs/domain-event.md | Why ordering uses a counter and not a timestamp; why eventName is static and cannot be checked. |
| docs/handle-decorator.md | Why registration keys on eventName rather than the class name, and what broke when it did not. |
| docs/handle-registry.md | Dropping reflect-metadata for a WeakMap; the prototype walk; why AggregateRootClass avoids a construct signature. |
Repo-root docs/ holds the investigation records these refer
back to — a change that was made, what it cost, and what it fixed:
inheritance-to-composition.md,
state-manager-init.md,
operation-atomicity.md,
abstract-base-construction.md.
It also holds two reference notes on how the building blocks divide up:
domain-event-ownership.md— whereEntitystops andAggregateRootstarts, event-wise. Start there if you are unsure whether something shouldrecordorapply.entity-aggregate-membership.md— which entities may exist where. Start there if you are unsure whether something should be a child entity, its own aggregate root, or a value object.
Tests
test/ mirrors the concerns rather than the files: construction.test.ts,
identity.test.ts, events.test.ts, boundary.test.ts, atomicity.test.ts,
and state.test.ts — which holds the Entity/StateManager seam, the part of
the state contract that cannot be tested inside @domain-toolkit/state itself.
Models the suites are written against live in test/models/.
Releasing
Two commands, from this directory. Each refuses to run on a bad state, so the order is the only thing to remember.
yarn release --minor # or --patch, --major, --new-version 0.3.0-rc.1
npm publishyarn release is yarn version. It writes the new version into
package.json, commits that one file as chore: release 0.3.0 — the message
comes from the repo-root .yarnrc — and creates the annotated tag v0.3.0
carrying the same message. Three lifecycle hooks in package.json hang off it
and off npm publish:
| Hook | Runs | Gates |
| ---------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------- |
| preversion | before the version is written | check-staged, then lint, typecheck, test — staged changes or a red suite stop the release before any commit or tag exists. |
| postversion | after the commit and tag | git push --follow-tags, so the tag reaches origin with its commit. |
| prepublishOnly | before npm publish, and only there | build, then check:dist — a publish always ships a fresh dist that has passed the check. |
Both checks are one-screen shell scripts in scripts/.
check-staged.sh exists because yarn version stages only the manifest and
never looks at the index, so anything already staged would be swept into the
release commit. check-dist.sh is the same line CI runs after every build: it
refuses a bundle in which the private @domain-toolkit/state survived as an
import specifier.
Why yarn for the version and npm for the publish
Yarn 1's version is the right tool here and its publish is not. yarn
version walks up to the git root, stages this package's manifest alone, and
leaves yarn.lock untouched; npm version in a workspace member creates no
commit and no tag, and rewrites the lockfiles with the wrong package manager.
yarn publish, on the other hand, prompts for a new version and bumps and tags
as a side effect of publishing — exactly the coupling to avoid. npm publish
does one thing, honours files and exports as written, and touches nothing
else.
