@ministryplace/kaironic
v0.1.0
Published
Event-sourced domain model toolkit for TypeScript that puts Developer Experience first
Downloads
5
Readme
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
Examples
The 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
cd examples
pnpm install
pnpm run bank-accountFeatures 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
npm install kaironic reflect-metadata
# or
yarn add kaironic reflect-metadata
# or
pnpm add kaironic reflect-metadataConcepts
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 richmetadataincludingaggregate,aggregateId,version,occurredAt, and optionalcorrelationId,causationId,idempotencyKey.Invariant Synchronous rule that must hold before and after commands. Returns
Ok(void)orErr(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:namestring overridesnapshotEverynumberinitialState: () => SstreamCategory?: stringdoc?: stringfreeform description
@DomainCommand(options?)Wraps a method as a command. Options:idempotencyKey?: (...args) => string | undefinedcorrelationId?: (...args) => string | undefineddoc?: string
@DomainInvariant(doc?)Registers a rule method that returnsResult<void, DomainRuleViolation>.@OnDomainEvent(type, doc?)Registers an event applier method with signature(event: DomainEvent, draft: S): void. Directly mutate thedraftparameter to update state.
Aggregate lifecycle
- Construct aggregate with id, state seeded from
initialState. - Execute a command; invariants are checked before and after.
- Command returns one or more events; they are stamped, applied, and staged as uncommitted.
- Repository
savepersists events with optimistic concurrency; snapshots may be taken. - Outbox is optionally written inside the same transaction.
- Aggregate clears its uncommitted events and updates
originalVersion.
Persistence interfaces
Implement these to back Kaironic with your database:
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:
- Loads the most recent snapshot
- Loads events after the snapshot version
- 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
idempotencyKeyin@DomainCommandoptions to make commands naturally retry-safe. - In-memory guard prevents accidental re-application during the aggregate’s lifetime.
- Store-level
hasIdempotencyKeyallows 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:
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
typestring. Plain objects work too.Do I need snapshots For small streams, no. For long-lived aggregates, snapshots reduce load time. Turn on with
snapshotEveryor 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
