Tactical Domain-Driven Design building blocks for TypeScript.
@shirudo/ddd-kit supplies the main parts for a domain model. These parts
include value objects, entities, aggregates, domain events, and repositories.
The package also supplies application handlers, outbox ports, projections, and
adapter contract tests.
It is not an application framework. You keep your HTTP layer, database, queue, ORM, and runtime choices. The kit gives your domain model a strong center and clear boundaries around persistence and side effects.
Release candidate: 3.0 (
3.0.0-rc, npm dist-tagnext). Latest stable release is 2.2.The public API follows Semantic Versioning. Breaking changes bump the major version and are documented with migration notes in the CHANGELOG.
Use this kit when your TypeScript code has domain rules that deserve more than DTOs and service functions:
- an order can only be confirmed once
- a booking must stay inside an allowed date range
- money must never lose precision at a JSON boundary
- optimistic concurrency conflicts must be handled deliberately
- domain events must be persisted and dispatched reliably
- repository adapters must prove they enforce the same contract
The library is intentionally boring at the edges. It does not ship an ORM, a message broker, decorators, a dependency-injection container, or a web framework. Those choices belong to the application.
pnpm add @shirudo/ddd-kit @shirudo/result @shirudo/base-error@shirudo/result and @shirudo/base-error are peer dependencies. Install them
once in the consuming app.
The package is ESM-only, requires TypeScript 5.9+, and supports Node 22+, Cloudflare Workers, Vercel Edge, Deno, and Bun.
import {
AggregateRoot,
DomainError,
type DomainEvent,
type Id,
} from "@shirudo/ddd-kit";
type OrderId = Id<"OrderId">;
type OrderState = {
status: "draft" | "confirmed";
};
type OrderConfirmed = DomainEvent<
"OrderConfirmed",
{ orderId: OrderId }
>;
type OrderEvent = OrderConfirmed;
class OrderAlreadyConfirmedError extends DomainError<
"ORDER_ALREADY_CONFIRMED"
> {
constructor(orderId: OrderId) {
super({
code: "ORDER_ALREADY_CONFIRMED",
message: `Order ${orderId} is already confirmed.`,
});
}
}
class Order extends AggregateRoot<OrderState, OrderId, OrderEvent> {
protected readonly aggregateType = "Order";
private constructor(id: OrderId, state: OrderState) {
super(id, state);
}
static draft(id: OrderId): Order {
return new Order(id, { status: "draft" });
}
get status(): OrderState["status"] {
return this.state.status;
}
confirm(): void {
if (this.state.status === "confirmed") {
throw new OrderAlreadyConfirmedError(this.id);
}
this.commit(
{ status: "confirmed" },
this.createEvent("OrderConfirmed", { orderId: this.id }),
);
}
}
const order = Order.draft("order-1" as OrderId);
order.confirm();
order.status; // "confirmed"
order.version; // 1
order.pendingEvents[0]?.type; // "OrderConfirmed" That example is deliberately small, but it shows the core shape:
- The aggregate owns the rule.
- The domain throws an error when an invariant is broken.
commit(...)changes the state and records the event together.createEvent(...)captures the immutable domain decision and aggregate source.- The application shell adds event identity, recording time, and trace metadata.
- Persistence stays outside the aggregate.
In production, a repository and withCommit or UnitOfWork persist the state,
write the events to an outbox inside the same transaction, and mark the
aggregate as persisted after the transaction commits.
Domain modeling
- value objects via
vo()andValueObject<T> - exact Money helpers in
@shirudo/ddd-kit/money - child entities with branded identity
- state-stored and event-sourced aggregate roots
- domain events with metadata, schema version, and commit stamps
- a domain state machine for named lifecycle states
Application boundaries
CommandHandlerandQueryHandlertypes- in-process
CommandBusandQueryBusfor modular apps, tests, and edge runtimes - a clear error split: domain code throws, command/query boundaries return
Result voValidatedfor collecting field-level validation issues- optional HTTP/RFC 9457 presentation helpers
Persistence and delivery
- repository interfaces for id-based and filtered access
- a per-operation Identity Map contract
- optimistic concurrency errors and duplicate-insert errors
withCommitfor transaction, outbox, event harvest, and post-commit cleanupUnitOfWorkfor repository registration and enrollment- outbox dispatcher, projection, event-store, and snapshot ports
- contract tests for repository and outbox adapters
The kit does not decide your architecture for you. It gives you hard boundaries where the domain model needs them and stays out of the rest.
- No ORM adapter is bundled.
- No queue or broker is required.
- No global application container is introduced.
- No query DSL or expression trees:
Specificationevaluates in memory and is translated explicitly by adapters, never reverse-engineered into SQL. - No money rounding, allocation, or FX policy is hidden in the library.
- No cross-process command bus is pretended to be in-process code.
Those are application decisions. The guides show the recommended seams.
Start with Getting Started if you want the short walkthrough. Read Design Decisions if you want to understand why the kit is shaped this way. Keep Common Mistakes nearby when writing your first adapter or aggregate.
| Topic | Guide |
|---|---|
| Value objects and validation helpers | Value Objects |
| Exact money values | Money |
| Child entities and identity | Entities |
| State-stored aggregates | Aggregate Roots |
| Event-sourced aggregates and snapshots | Event Sourcing |
| Domain event shape and factories | Domain Events |
| Named lifecycle states | Domain State Machine |
Throwing in the domain, returning Result at the boundary |
Result vs Throw |
| Commands, queries, and in-process buses | CQRS & Buses |
| Repository contracts and Identity Map | Repository |
| Transaction-scoped repositories | Unit of Work |
| Duplicate-safe commands and inbox handling | Command Idempotency |
| Reliable event harvest and delivery | Outbox & Transactions |
| Read models and projectors | Projections |
| Event schema changes | Event Upcasting |
| Optimistic concurrency | Concurrency |
| Workers, Deno, Bun, and other edge runtimes | Edge Runtimes |
The generated API reference lives in docs/api.
- examples/order: a minimal state-stored aggregate
- examples/order-with-entity-items: an aggregate with child entities
- examples/rugby: an event-sourced aggregate
- examples/saga: state-stored and event-sourced process manager / saga variants
pnpm typecheck runs the native TypeScript 7 compiler. The typescript
development dependency intentionally aliases the official TypeScript 6
compatibility package because TypeDoc and Vite+ Pack's declaration bundler still
consume the compiler API, which TypeScript 7.0 does not expose. Keep the two
packages side by side until those API-based tools support TypeScript 7.
Tests and package builds run through Vite+ (pnpm test, pnpm build). Biome
remains the repository's lint and format policy.
Bug reports, questions, and pull requests are welcome on
GitHub. Please open pull requests
against main.
MIT.
Shirudo: @shi-rudo | npm | repository