npm package: @ministryplace/kaironic
Description: Event-sourced domain model toolkit for TypeScript that puts Developer Experience first
Author: David Hayes
Latest version: 0.1.0

# Kaironic

[![npm version][npm-version-src]][npm-version-href]
[![npm downloads][npm-downloads-src]][npm-downloads-href]
[![Github Actions][github-actions-src]][github-actions-href]
[![Codecov][codecov-src]][codecov-href]

> Kaironic helps you model complex business processes without drowning in boilerplate.

🚧 Under active development

Kaironic helps you model complex business processes without drowning in boilerplate.

Event-sourced domain model toolkit for TypeScript that puts Developer Experience first. Define aggregates with decorators, emit strongly-typed domain events, enforce invariants, and persist with pluggable stores. Snapshots, optimistic concurrency, idempotency, and an optional outbox are built in.

## Highlights

* Declarative decorators for aggregates, commands, invariants, and event handlers
* Strict mutation control: all state changes happen inside event appliers
* Serializable state and events with rich metadata (correlation, causation, idempotency)
* Version tracking and optimistic concurrency baked in
* Rehydrate from snapshot plus history with pluggable snapshot strategy
* Optional outbox for reliable, transactional event publishing
* Minimal test DSL (`given/when/then`) for readable specs
* Zero runtime deps except `reflect-metadata`

## Documentation

- [Quick Start](./docs/quick-start.md)
- [API Docs](./docs/api/index.md)

## Examples

The [examples](./examples/) directory contains complete, working applications demonstrating Kaironic features:

### Bank Account Example

A comprehensive example showing:
- Event-sourced aggregates with decorators
- Class-based domain events with perfect type inference
- Domain invariants and business rules
- Optimistic concurrency control
- Idempotency handling
- Error handling patterns
- Outbox pattern for reliable event publishing
- Audit logging and notifications
- Testing with the DSL

```bash
cd examples
pnpm install
pnpm run bank-account
```

Features demonstrated:
- **Event Sourcing**: Complete event-sourced bank account
- **Domain Modeling**: Aggregates, events, commands, and invariants
- **Error Handling**: Domain rule violations and recovery
- **Concurrency**: Optimistic locking with conflict resolution
- **Idempotency**: Duplicate prevention with keys
- **Outbox Pattern**: Reliable event publishing
- **Testing**: Given/When/Then test patterns
- **Notifications**: Event-driven architecture
- **Audit Trail**: Compliance and observability

### Running Examples

Each example includes:
- Complete TypeScript implementation
- Comprehensive tests
- Demo scripts showing real usage
- README with detailed explanations

The examples serve as both learning resources and reference implementations for production use.

## Install

```bash
npm install kaironic reflect-metadata
# or
yarn add kaironic reflect-metadata
# or
pnpm add kaironic reflect-metadata
```

## Concepts

* Aggregate Root
  Your consistency boundary. Tracks serializable state, versions, and uncommitted events. Mutations are only allowed inside event appliers.

* Domain Event
  Immutable fact with `type`, `data`, and rich `metadata` including `aggregate`, `aggregateId`, `version`, `occurredAt`, and optional `correlationId`, `causationId`, `idempotencyKey`.

* Invariant
  Synchronous rule that must hold before and after commands. Returns `Ok(void)` or `Err(DomainRuleViolation)`.

* Command
  Business operation that returns `Result<DomainEvent[] | object[], DomainRuleViolation>`. Kaironic converts plain objects or class instances to fully-stamped events, applies them, and accumulates them for persistence.

* Repository
  Loads an aggregate from snapshot plus history and saves uncommitted events with optimistic concurrency, snapshotting, and optional outbox write.

* Entity
  Base class for domain entities with identity and mutable state. Provides JSON serialization and identity comparison.

* Value Object
  Base class for immutable value objects. Provides structural equality comparison and JSON serialization.

## Decorators

* `@DomainAggregate(options)`
  Registers the aggregate. Options:

  * `name` string override
  * `snapshotEvery` number
  * `initialState: () => S`
  * `streamCategory?: string`
  * `doc?: string` freeform description

* `@DomainCommand(options?)`
  Wraps a method as a command. Options:

  * `idempotencyKey?: (...args) => string | undefined`
  * `correlationId?: (...args) => string | undefined`
  * `doc?: string`

* `@DomainInvariant(doc?)`
  Registers a rule method that returns `Result<void, DomainRuleViolation>`.

* `@OnDomainEvent(type, doc?)`
  Registers an event applier method with signature `(event: DomainEvent, draft: S): void`. Directly mutate the `draft` parameter to update state.

## Aggregate lifecycle

1. Construct aggregate with id, state seeded from `initialState`.
2. Execute a command; invariants are checked before and after.
3. Command returns one or more events; they are stamped, applied, and staged as uncommitted.
4. Repository `save` persists events with optimistic concurrency; snapshots may be taken.
5. Outbox is optionally written inside the same transaction.
6. Aggregate clears its uncommitted events and updates `originalVersion`.

## Persistence interfaces

Implement these to back Kaironic with your database:

```ts
export interface EventStore {
  append: (aggregate: string, id: string, events: DomainEvent[], expectedVersion: number) => Promise<Result<void, OptimisticConcurrencyViolation>>
  load: (aggregate: string, id: string, afterVersion: number) => Promise<DomainEvent[]>
  getVersion: (aggregate: string, id: string) => Promise<number>
  hasIdempotencyKey?: (aggregate: string, id: string, key: string) => Promise<boolean>
}

export interface SnapshotStore {
  load: (aggregate: string, id: string) => Promise<AggregateSnapshot | null>
  save: (s: AggregateSnapshot) => Promise<void>
}
```

Transactional variants are supported for atomic append plus snapshot (and outbox) with `begin/commit/rollback`.

## Snapshot strategies

Snapshots improve performance by reducing the number of events that need to be loaded and replayed. Kaironic provides flexible snapshot strategies to control when snapshots are taken.

### Built-in Strategies

If no strategy is provided, the repository uses the aggregate's built-in `shouldSnapshot()` method, which respects the `snapshotEvery` option from `@DomainAggregate`.

You might also consider other strategies, like time-based or size-based.

### Performance Considerations

* **Small aggregates**: May not need snapshots at all
* **Large aggregates**: Benefit from frequent snapshots (every 50-200 events)
* **Write-heavy aggregates**: Consider size or time-based strategies
* **Read-heavy aggregates**: More frequent snapshots improve load performance

### Snapshot Storage

Snapshots are stored separately from events.

When loading an aggregate, the repository:
1. Loads the most recent snapshot
2. Loads events after the snapshot version
3. Replays events on top of the snapshot state

## Outbox publishing

Add an `Outbox` implementation to `AggregateRepository` to stage committed events for reliable, transactional publishing to message brokers or webhooks. Use a separate process to drain the outbox.

## Idempotency and retries

* Provide `idempotencyKey` in `@DomainCommand` options to make commands naturally retry-safe.
* In-memory guard prevents accidental re-application during the aggregate’s lifetime.
* Store-level `hasIdempotencyKey` allows dedup across restarts; back it with an index like `(aggregate, aggregate_id, metadata->>'idempotencyKey')`.

## Optimistic concurrency

Repositories perform a preflight `getVersion` check and enforce `expectedVersion` on append. On mismatch, you receive `OptimisticConcurrencyViolation` with expected and actual versions so you can reload, recompute, and retry.

## Entities and Value Objects

Kaironic provides base classes for domain entities and value objects to help structure your domain model.

## Introspection and self-documentation

Kaironic provides runtime introspection capabilities to help with documentation, testing, and operational visibility.

### Aggregate Introspection

Use `AggregateRoot.describe()` to get metadata about your aggregates:

```ts
const description = AggregateRoot.describe(accountInstance)

console.log(description)
/*
{
  doc: 'Simple event-sourced bank account with negative-balance and closed-account invariants.',
  invariants: [
    { name: 'mustNotBeNegative', doc: 'Balance must never be negative' },
    { name: 'cannotOperateIfAlreadyClosed', doc: 'Cannot operate on closed accounts' }
  ],
  commands: [
    { name: 'open', doc: 'Open a new account with duplicate detection' },
    { name: 'deposit', doc: 'Deposit positive funds' },
    { name: 'withdraw', doc: 'Withdraw positive funds' },
    { name: 'close', doc: 'Close account when balance is zero' }
  ],
  handlers: [
    { event: 'AccountOpened', handler: 'onOpened', doc: 'Set currency and openedAt' },
    { event: 'FundsDeposited', handler: 'onDeposited', doc: 'Increase balance' },
    { event: 'FundsWithdrawn', handler: 'onWithdrawn', doc: 'Decrease balance' },
    { event: 'AccountClosed', handler: 'onClosed', doc: 'Mark as closed' }
  ]
}
*/
```

### Use Cases

* **API Documentation**: Generate OpenAPI specs from aggregate metadata
* **Admin Interfaces**: Build management UIs that show available operations
* **Testing**: Validate that all expected commands and events are implemented
* **Monitoring**: Track aggregate usage patterns and performance
* **Migration Planning**: Understand the current domain model structure

## Production adapters

Postgres event store (suggested schema):

* `events(stream_key text, aggregate text, aggregate_id text, version int, id uuid, type text, data jsonb, metadata jsonb, occurred_at timestamptz, primary key (stream_key, version))`
* Unique index for idempotency: `(aggregate, aggregate_id, (metadata->>'idempotencyKey')) where (metadata->>'idempotencyKey') is not null`
* `snapshots(aggregate text, id text, version int, state jsonb, captured_at timestamptz, primary key (aggregate, id))`
* `outbox(id uuid primary key, available_at timestamptz, payload jsonb, status text, retries int default 0)`

## Design choices

* Event applications are the only place state can change. Any attempt to mutate elsewhere throws with an explanatory message.
* Commands are pure in the sense that they return events; side effects (like publishing) happen after persistence via the outbox pattern.
* JSON-serializable state and events keep persistence adapters simple and portable.

## FAQ

* Can I use class-based events
  Yes. Give the class a static `type` string. Plain objects work too.

* Do I need snapshots
  For small streams, no. For long-lived aggregates, snapshots reduce load time. Turn on with `snapshotEvery` or a custom strategy.

* How do I handle cross-aggregate transactions
  Use `repo.saveAll([aggA, aggB])` with a transactional store. Otherwise, model sagas/process managers driven by events from the outbox.

## Contributing

Issues and PRs are welcome. Please include tests and keep DX front and center.

## License

MIT