@shirudo/ddd-kit 1.0.0-rc.1 → 1.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Composable TypeScript toolkit for tactical Domain-Driven Design.
4
4
 
5
+ 📚 **[Full documentation site](https://shi-rudo.github.io/ddd-kit-ts/)** — guides, API reference, design decisions.
6
+
5
7
  > **Release Candidate**
6
8
  >
7
9
  > This library is in Release Candidate phase. The API is considered stable and ready for production evaluation. Please report any issues before the final 1.0.0 release.
@@ -62,7 +64,7 @@ const email = createEmail("user@example.com");
62
64
 
63
65
  ### Value Objects
64
66
 
65
- Value Objects are immutable objects that are defined by their attributes rather than identity. They ensure data integrity by preventing modification after creation. Use the `vo()` helper function to create deeply frozen value objects that cannot be mutated, even nested objects and arrays. The library provides `voEquals()` for value-based equality comparison, `voEqualsExcept()` for comparing while ignoring specified keys (useful for metadata), `voWithValidation()` for creating validated value objects (returns Result), and `voWithValidationUnsafe()` for the exception-throwing variant.
67
+ Value Objects are immutable objects that are defined by their attributes rather than identity. They ensure data integrity by preventing modification after creation. Use the `vo()` helper function to create deeply frozen value objects that cannot be mutated, even nested objects and arrays. The library provides `voEquals()` for value-based equality comparison, `voEqualsExcept()` for comparing while ignoring specified keys (useful for metadata), and `voWithValidation()` for creating validated value objects at the App-Service boundary (returns Result). For Domain construction, prefer the `ValueObject` base class — its constructor throws on invariant violation via the `validate()` hook.
66
68
 
67
69
  ### Entities
68
70
 
@@ -170,19 +172,16 @@ const result = voWithValidation(
170
172
  "Amount must be non-negative and currency must be 3 characters"
171
173
  );
172
174
 
173
- if (result.ok) {
175
+ if (result.isOk()) {
174
176
  const validMoney = result.value;
175
177
  // Use validMoney...
176
178
  } else {
177
179
  console.error(result.error);
178
180
  }
179
181
 
180
- // Or use unsafe variant (throws exception)
181
- const validMoneyUnsafe = voWithValidationUnsafe(
182
- { amount: 100, currency: "USD" },
183
- (m) => m.amount >= 0 && m.currency.length === 3,
184
- "Amount must be non-negative and currency must be 3 characters"
185
- );
182
+ // For Domain construction, use the `ValueObject` base class — its constructor
183
+ // throws via the `validate()` hook, so Domain code keeps a throw-based contract.
184
+ // Reserve `voWithValidation` for parsing untrusted input at the App boundary.
186
185
 
187
186
  // Value object with nested structures (deep freeze)
188
187
  const address = vo({
@@ -308,6 +307,48 @@ class Order extends AggregateRoot<OrderState, OrderId, OrderDomainEvent> {
308
307
  // order.addDomainEvent({ type: "WrongEvent" }) → compile error
309
308
  ```
310
309
 
310
+ > **Domain-event ordering: record AFTER mutation.** A domain event represents something that has *just happened* to the aggregate. Always mutate state first (`setState`, invariant checks), then `addDomainEvent`. Recording before mutation is a footgun: if a subsequent invariant throws, the event has been queued for a fact that never actually happened.
311
+ >
312
+ > Two paths give you that ordering for free, so you don't have to remember the rule:
313
+ >
314
+ > - **`EventSourcedAggregate.apply(event)`** — `validateEvent` runs, then the handler computes the next state, then state + event + version commit atomically. State is never mutated without the event, and the event is never recorded without the state.
315
+ > - **`AggregateRoot.commit(newState, event)`** — opt-in helper that runs `setState(newState)` first (which throws on `validateState` failure) and only then appends the event(s). Use this instead of calling `setState` + `addDomainEvent` separately:
316
+ >
317
+ > ```ts
318
+ > confirm(): void {
319
+ > if (this.state.status === "confirmed") throw new OrderAlreadyConfirmedError(this.id);
320
+ > this.commit(
321
+ > { ...this.state, status: "confirmed" },
322
+ > { type: "OrderConfirmed", orderId: this.id },
323
+ > );
324
+ > }
325
+ > ```
326
+ >
327
+ > `commit()` accepts a single event, an array of events, or none. Direct `setState`/`addDomainEvent` calls remain available for cases that don't fit the helper (state-only mutations, audit-only events, multi-step transactions).
328
+
329
+ > **Aggregate methods own the behaviour, not the consumer.** Subclasses of `AggregateRoot` and `EventSourcedAggregate` expose state via the `state` getter (DDD requires invariant checks to read it), but the canonical Pattern is *Tell, Don't Ask*: write business methods on the aggregate that mutate via `commit()` / `setState()` / `apply()` and emit events; do not write `if (order.state.status === "draft") order.state.status = "confirmed"` from outside the aggregate. The state getter is for the aggregate's own methods and for read-only projections — not as a public mutation handle.
330
+
331
+ ### Event-Sourcing Schema Evolution (Upcasting)
332
+
333
+ `DomainEvent.version` is intentionally a plain integer rather than a library-managed migration chain. Schema evolution is **the consumer's responsibility** — every event store handles it differently (sync upcasters in the load path, async upcasters in a projection rebuild, schema-registry coupling, etc.). The recommended pattern is to wrap your event-store read path:
334
+
335
+ ```ts
336
+ // At the infrastructure boundary, before passing events to loadFromHistory:
337
+ function upcast(event: PersistedEvent): DomainEvent {
338
+ if (event.type === "OrderCreated" && event.version === 1) {
339
+ // v1 → v2 migration; produce a new DomainEvent
340
+ return { ...event, version: 2, payload: { ...event.payload, currency: "EUR" } };
341
+ }
342
+ return event;
343
+ }
344
+
345
+ const history = await eventStore.read(aggregateId);
346
+ const upcasted = history.map(upcast);
347
+ aggregate.loadFromHistory(upcasted);
348
+ ```
349
+
350
+ The library deliberately ships no `EventUpcaster` port. Real upcasting strategies vary too much (chained vs schema-registry vs lazy) to commit to one shape pre-1.0 without concrete usage data.
351
+
311
352
  ### Creating an Aggregate WITH Event Sourcing
312
353
 
313
354
  ```typescript
@@ -350,28 +391,22 @@ class Order extends EventSourcedAggregate<OrderState, OrderEvent, OrderId> {
350
391
  }
351
392
 
352
393
  confirm(): void {
353
- const result = this.apply(
354
- createDomainEvent("OrderConfirmed") as OrderConfirmed
355
- );
356
- if (!result.ok) {
357
- throw new Error(result.error);
358
- }
394
+ this.apply(createDomainEvent("OrderConfirmed") as OrderConfirmed);
359
395
  }
360
396
 
361
397
  ship(trackingNumber: string): void {
362
- const result = this.apply(
398
+ this.apply(
363
399
  createDomainEvent("OrderShipped", { trackingNumber }) as OrderShipped
364
400
  );
365
- if (!result.ok) {
366
- throw new Error(result.error);
367
- }
368
401
  }
369
402
 
370
- // Or use unsafe variant (throws exception directly)
371
- confirmUnsafe(): void {
372
- this.applyUnsafe(
373
- createDomainEvent("OrderConfirmed") as OrderConfirmed
374
- );
403
+ // Override `validateEvent` to throw a DomainError subclass when an invariant
404
+ // is violated (e.g. confirming an already-confirmed order). `apply()` itself
405
+ // throws `MissingHandlerError` when no handler is registered for the event.
406
+ protected validateEvent(event: OrderEvent): void {
407
+ if (event.type === "OrderConfirmed" && this.state.status === "confirmed") {
408
+ throw new OrderAlreadyConfirmedError(this.id);
409
+ }
375
410
  }
376
411
 
377
412
  protected readonly handlers = {
@@ -490,19 +525,24 @@ class Order extends EventSourcedAggregate<OrderState, OrderEvent, OrderId> {
490
525
 
491
526
  ### Using CQRS: Commands and Queries
492
527
 
528
+ The library ships an in-memory `CommandBus` and `QueryBus`. These are zero-config in-process dispatchers — they fit:
529
+
530
+ - **Edge runtimes** (Cloudflare Workers, Vercel Edge, Deno Deploy, Bun): each worker invocation handles one command in-process; external brokers would defeat edge latency.
531
+ - **Modular monoliths**: a single Node process with several bounded contexts; the bus routes commands between modules. Domain events still leave the process via outbox/external bus when other services need them.
532
+ - **Tests and local development**: stand-in for production buses without infrastructure.
533
+ - **Small CLIs and scripts**: CQRS structure without infrastructure.
534
+
535
+ For **cross-process messaging** (RabbitMQ, NATS, Kafka, AWS SQS, etc.), don't use the in-memory bus — keep the `CommandHandler<C, R>` / `QueryHandler<Q, R>` types as the contract and wire them to your transport of choice. The handlers stay portable; only the dispatcher changes.
536
+
537
+ The included buses intentionally have no middleware/pipeline machinery — wrap handlers with decorator functions when you need logging, auth, metrics. Anything more elaborate is "in-house framework" territory and lives outside the kit.
538
+
493
539
  #### Commands (Write Operations)
494
540
 
495
541
  Commands represent write operations that change system state. They return `Result` for explicit error handling.
496
542
 
497
543
  ```typescript
498
- import {
499
- Command,
500
- CommandHandler,
501
- CommandBus,
502
- ok,
503
- err,
504
- type Result,
505
- } from "@shirudo/ddd-kit";
544
+ import { Command, CommandHandler, CommandBus } from "@shirudo/ddd-kit";
545
+ import { ok, err, type Result } from "@shirudo/result";
506
546
 
507
547
  // Define a command
508
548
  type CreateOrderCommand = Command & {
@@ -541,7 +581,7 @@ const result = await createOrderHandler({
541
581
  items: [{ productId: "product-1", quantity: 2, price: 10.0 }],
542
582
  });
543
583
 
544
- if (result.ok) {
584
+ if (result.isOk()) {
545
585
  console.log("Order created:", result.value);
546
586
  } else {
547
587
  console.error("Error:", result.error);
@@ -600,7 +640,7 @@ const result = await queryBus.execute({
600
640
  orderId: "order-123",
601
641
  });
602
642
 
603
- if (result.ok) {
643
+ if (result.isOk()) {
604
644
  const orderFromBus = result.value;
605
645
  // Use orderFromBus...
606
646
  } else {
@@ -714,7 +754,7 @@ channel.consume("order.commands", async (message) => {
714
754
  const command = JSON.parse(message.content.toString()) as CreateOrderCommand;
715
755
  const result = await createOrderHandler(command);
716
756
 
717
- if (result.ok) {
757
+ if (result.isOk()) {
718
758
  channel.ack(message);
719
759
  } else {
720
760
  channel.nack(message, false, true); // Requeue on error
@@ -1063,26 +1103,7 @@ The `Result<T, E>` type provides composition utilities to avoid repetitive `if (
1063
1103
  **Import Result utilities from the dedicated export path:**
1064
1104
 
1065
1105
  ```typescript
1066
- import {
1067
- ok,
1068
- err,
1069
- isOk,
1070
- isErr,
1071
- andThen,
1072
- map,
1073
- mapErr,
1074
- unwrapOr,
1075
- unwrapOrElse,
1076
- match,
1077
- matchAsync,
1078
- pipe,
1079
- tryCatch,
1080
- tryCatchAsync,
1081
- type Result,
1082
- Outcome,
1083
- Success,
1084
- Erroneous
1085
- } from "@shirudo/ddd-kit/result";
1106
+ import { ok, err, type Result } from "@shirudo/result";
1086
1107
 
1087
1108
  type UserId = string;
1088
1109
 
@@ -1090,101 +1111,35 @@ function validateUserId(id: string): Result<UserId, string> {
1090
1111
  return id.length > 0 ? ok(id as UserId) : err("User ID cannot be empty");
1091
1112
  }
1092
1113
 
1093
- function validateEmail(email: string): Result<string, string> {
1094
- return email.includes("@") ? ok(email) : err("Invalid email");
1095
- }
1096
-
1097
- // Chaining operations with andThen (avoids if-checks)
1098
- function createUser(id: string, email: string): Result<{ id: UserId; email: string }, string> {
1099
- return andThen(validateUserId(id), (userId) =>
1100
- map(validateEmail(email), (email) => ({
1101
- id: userId,
1102
- email,
1103
- }))
1104
- );
1105
- }
1106
-
1107
- // Using map for transformations
1108
- const result = ok(5);
1109
- const doubled = map(result, x => x * 2); // Ok<10>
1110
-
1111
- // Using mapErr to transform errors
1112
- const errorResult = err("not found");
1113
- const mappedError = mapErr(errorResult, e => `Error: ${e}`); // Err<"Error: not found">
1114
-
1115
- // Using unwrapOr for defaults
1116
- const userId = unwrapOr(validateUserId(""), "default-id");
1117
-
1118
- // Using unwrapOrElse for computed defaults
1119
- const userId2 = unwrapOrElse(validateUserId(""), err => `fallback-${Date.now()}`);
1120
-
1121
- // Using match for pattern matching
1122
- const message = match(createUser("user-123", "test@example.com"),
1123
- user => `User created: ${user.id}`,
1124
- error => `Error: ${error}`
1125
- );
1126
-
1127
- // Usage with type guards (still works)
1128
- const result2 = createUser("user-123", "test@example.com");
1129
- if (isOk(result2)) {
1130
- console.log("User created:", result2.value);
1114
+ const result = validateUserId("user-123");
1115
+ if (result.isOk()) {
1116
+ console.log("Valid:", result.value);
1131
1117
  } else {
1132
- console.error("Error:", result2.error);
1118
+ console.error("Invalid:", result.error);
1133
1119
  }
1120
+ ```
1134
1121
 
1135
- // Using tryCatch to wrap functions that throw exceptions
1136
- function riskyOperation(): string {
1137
- if (Math.random() > 0.5) {
1138
- throw new Error("Something went wrong");
1139
- }
1140
- return "success";
1141
- }
1122
+ `ddd-kit` declares `@shirudo/result` as a `peerDependency`, so install it once in your app:
1142
1123
 
1143
- const result3 = tryCatch(() => riskyOperation());
1144
- if (result3.ok) {
1145
- console.log(result3.value); // "success"
1146
- } else {
1147
- console.error(result3.error.message); // "Something went wrong"
1148
- }
1124
+ ```bash
1125
+ pnpm add @shirudo/result
1126
+ ```
1149
1127
 
1150
- // Using tryCatchAsync for async operations
1151
- async function riskyAsyncOperation(): Promise<string> {
1152
- if (Math.random() > 0.5) {
1153
- throw new Error("Async error");
1154
- }
1155
- return "async success";
1156
- }
1128
+ For composition utilities (`map`, `flatMap`, `mapErr`, `match`, `unwrapOr`, `pipe`, `tryCatch`, async variants, etc.), refer to the [`@shirudo/result` documentation](https://www.npmjs.com/package/@shirudo/result).
1157
1129
 
1158
- const result4 = await tryCatchAsync(() => riskyAsyncOperation());
1159
- match(result4,
1160
- (value) => console.log("Success:", value),
1161
- (error) => console.error("Error:", error.message)
1162
- );
1163
- ```
1130
+ **Where `ddd-kit` uses `Result` vs `throw`:**
1164
1131
 
1165
- **Available Composition Utilities:**
1166
- - `andThen<T, E, U>(result, fn)` - Chains Result operations (flatMap/bind). If Ok, applies function; if Err, returns error unchanged.
1167
- - `map<T, E, U>(result, fn)` - Transforms Ok value. If Err, returns error unchanged.
1168
- - `mapErr<T, E, F>(result, fn)` - Transforms Err value. If Ok, returns value unchanged.
1169
- - `unwrapOr<T, E>(result, defaultValue)` - Returns value if Ok, otherwise returns default.
1170
- - `unwrapOrElse<T, E>(result, fn)` - Returns value if Ok, otherwise computes default from error.
1171
- - `match<T, E, R>(result, onOk, onErr)` - Pattern matching. Applies one function if Ok, another if Err. Supports both function and object syntax.
1172
- - `matchAsync<T, E, R>(result, onOk, onErr)` - Asynchronous pattern matching. Applies async functions for Ok/Err cases. Supports both function and object syntax.
1173
- - `pipe<T, E>(initial, ...fns)` - Pipes a Result through multiple operations. Stops on first error. Cleaner alternative to nested `andThen` calls.
1174
- - `tryCatch<T, E>(fn, errorMapper?)` - Wraps a function that may throw exceptions into a Result type. Catches exceptions and converts them to Err results.
1175
- - `tryCatchAsync<T, E>(fn, errorMapper?)` - Wraps an async function that may throw exceptions into a Promise<Result>. Catches exceptions and Promise rejections.
1176
-
1177
- **Class-based API (for method chaining):**
1178
- - `Outcome<T, E>` - Wrapper class for Result with method chaining support
1179
- - `Success<T>` - Class representing successful results (created via `Ok()` factory)
1180
- - `Erroneous<E>` - Class representing error results (created via `Err()` factory)
1132
+ - **Domain layer throws** `DomainError`-derived exceptions. `EventSourcedAggregate.apply()`, the `validateEvent()` hook, and the `ValueObject` constructor all throw. Subclass `DomainError` for your aggregate-specific errors (e.g. `OrderAlreadyShippedError`) and catch via `instanceof`.
1133
+ - **Infrastructure boundary returns `Result`** where corruption is an expected recoverable failure: `EventSourcedAggregate.loadFromHistory()`, `restoreFromSnapshotWithEvents()`.
1134
+ - **App-Service boundary returns `Result`**: `CommandBus.execute()`, `QueryBus.execute()`, `CommandHandler<C,R>`, `QueryHandler<Q,R>`, `withCommit()`. This is where you map errors to HTTP statuses, logs, etc.
1135
+ - **`voWithValidation`** is the explicit Result variant for parsing untrusted input at the App boundary. For Domain construction, use the `ValueObject` base class (constructor throws via `validate()`).
1181
1136
 
1182
1137
  ## API Documentation
1183
1138
 
1184
1139
  This package is written in TypeScript and provides full type definitions. All types and functions are exported from the main entry point. You can explore the available APIs through your IDE's autocomplete or by examining the type definitions in `node_modules/@shirudo/ddd-kit/dist/index.d.ts`.
1185
1140
 
1186
1141
  Key exports include:
1187
- - `vo()`, `voEquals()`, `voEqualsExcept()`, `voWithValidation()`, `voWithValidationUnsafe()` - Value Object utilities
1142
+ - `vo()`, `voEquals()`, `voEqualsExcept()`, `voWithValidation()` - Value Object utilities (`voWithValidation` is for the App-Service boundary; Domain construction goes through the `ValueObject` base class which throws via `validate()`)
1188
1143
  - `IAggregateRoot<TId>` - Marker interface for Aggregate Root Entities
1189
1144
  - `AggregateRoot<TState, TId, TEvent?>` - Base class for creating Aggregate Root Entities without Event Sourcing (extends `Entity`, implements `IAggregateRoot<TId>`). Optional `TEvent` parameter enables type-safe domain events
1190
1145
  - `EventSourcedAggregate<TState, TEvent, TId>` - Base class for Event-Sourced Aggregate Roots (extends `Entity`, implements `IEventSourcedAggregate<TId, TEvent>`)
@@ -1210,14 +1165,12 @@ Key exports include:
1210
1165
  - `EventBus.subscribe()` - Subscribe handlers to event types
1211
1166
  - `EventBus.publish()` - Publish events to all subscribers (uses `Promise.allSettled` — all handlers run even if one fails)
1212
1167
  - `EventBus.once()` - Wait for the next event of a given type (returns Promise, auto-unsubscribes)
1213
- - `Result<T, E>`, `ok()`, `err()`, `isOk()`, `isErr()` - Result type and type guards
1214
- - `andThen()`, `map()`, `mapErr()` - Result composition utilities
1215
- - `unwrapOr()`, `unwrapOrElse()`, `match()` - Result unwrapping and pattern matching
1216
- - `Id<Tag>` - Branded ID type
1168
+ - `Id<Tag>` - Branded ID type (Result type and operators come from the `@shirudo/result` peer dep)
1217
1169
  - `IRepository<TAgg, TId>` - Repository interface
1218
- - `ISpecification<T>` - Specification interface
1219
- - `UnitOfWork` - Unit of Work interface
1220
- - `guard()` - Guard/validation helper
1170
+ - `IQueryableRepository<TAgg, TId, TFilter>` - Repository extension that adds filter-based querying. `TFilter` is the persistence layer's native filter shape (Drizzle SQL, Prisma WhereInput, Mongo filter doc, in-memory predicate, …). Repositories that are only accessed by id should implement `IRepository` directly and skip this extension.
1171
+ - `TransactionScope` - Transaction-scope abstraction (wraps a block of work in the persistence layer's native transaction). Intentionally minimal — not Fowler's full UoW with change tracking.
1172
+ - `DomainError` - Abstract base for domain exceptions (Consumer subclasses for their aggregate-specific errors)
1173
+ - `MissingHandlerError`, `AggregateNotFoundError` - Concrete library-internal `DomainError` subclasses
1221
1174
 
1222
1175
  ## Concurrency & Thread Safety
1223
1176