@seike460/minamo
v0.3.0
Published
Type-safe CQRS+ES for AWS Serverless
Maintainers
Readme
minamo
English | 日本語
Type-safe CQRS + Event Sourcing for AWS Serverless.
minamo is a CQRS + Event Sourcing library for TypeScript / Node 24 / AWS SDK v3 that stays thin, strict, and lets you write the write side in your domain's own words. It exposes exactly four public surfaces — Aggregate, Command, Event, and Projection Bridge — and never takes AWS primitives out of your hands.
- No SLA. Production guarantees are the consumer's responsibility
- Single-maintainer (@seike460). Pull requests welcome — see
GOVERNANCE.mdfor roles and the path to becoming a co-maintainer - MIT License
Status: published on npm as
@seike460/minamo(see the npm badge above for the latest version). The public API followsdocs/concept.md§5 verbatim. The path to v1 is tracked indocs/roadmap-v1.md.
Why minamo?
Doing CQRS + Event Sourcing on DynamoDB + Lambda + TypeScript? The write side is where correctness is hardest — optimistic locking, retry-from-load, and keeping your tests honest against production. minamo solves exactly that, and nothing more:
- DynamoDB-first, not multi-DB.
TransactWriteItems+ConsistentReadare baked into the design, not hidden behind a portable adapter. - Full-cycle retry as a core API.
executeCommandowns Load → Rehydrate → Decide → Append and retries the whole cycle onConcurrencyError— the wiring hand-rolled implementations get wrong. - InMemory ⇄ DynamoDB parity, guaranteed. Both stores run the same Contract Tests, so your tests can't pass while production breaks.
- One runtime dependency. AWS SDK v3 (optional peer). Minimal cold-start and dependency surface.
Full comparison with castore and @ocoda/event-sourcing: docs/concept.md §7. Not sure if you need CQRS+ES at all? §1–§2 of the concept doc help you decide.
Install
pnpm add @seike460/minamo
# AWS SDK v3 is an optional peer dependency — only needed when you use DynamoEventStore
pnpm add @aws-sdk/client-dynamodb @aws-sdk/lib-dynamodb @aws-sdk/util-dynamodb"type": "module", ESM only. Node ≥ 24- Include the
.jsextension inimportpaths (verbatimModuleSyntax)
Quick Start — InMemory in one minute
import {
type AggregateConfig,
type CommandHandler,
InMemoryEventStore,
executeCommand,
} from "@seike460/minamo";
type CounterEvents = {
Incremented: { amount: number };
};
const counter: AggregateConfig<number, CounterEvents> = {
initialState: 0,
evolve: {
Incremented: (state, data) => state + data.amount,
},
};
const increment: CommandHandler<number, CounterEvents, { amount: number }> = (agg, input) => {
if (input.amount === 0) return [];
return [{ type: "Incremented", data: { amount: input.amount } }];
};
const store = new InMemoryEventStore<CounterEvents>();
const { aggregate } = await executeCommand({
config: counter,
store,
handler: increment,
aggregateId: "counter-1",
input: { amount: 5 },
});
console.log(aggregate.state); // 5InMemoryEventStore is for tests and local exploration. In production you swap it for DynamoEventStore. Both implementations run the same Contract Tests, so behavioural drift between them is structurally constrained.
Production — switch to DynamoEventStore
import { DynamoEventStore } from "@seike460/minamo";
const store = new DynamoEventStore<CounterEvents>({
tableName: "events",
// Use clientConfig when you need region / credentials, or
// pass a pre-built DocumentClient via `client` (see API reference)
});
await executeCommand({
config: counter,
store,
handler: increment,
aggregateId: "counter-1",
input: { amount: 5 },
});Table schema (concept.md §3 / C11):
| attribute | kind | type |
|---|---|---|
| aggregateId | PK (HASH) | S |
| version | SK (RANGE) | N |
Enable StreamViewType=NEW_IMAGE and use parseStreamRecord in a Projection Lambda to build Read Models.
Projection — Stream → Read Model
import { eventNamesOf, parseStreamRecord } from "@seike460/minamo";
const accepted = eventNamesOf(counter); // ["Incremented"]
export const handler = async (event: { Records: unknown[] }) => {
for (const record of event.Records) {
const stored = parseStreamRecord<CounterEvents>(record, accepted);
if (stored === null) continue; // MODIFY / REMOVE records are skipped
// Unregistered event types throw InvalidStreamRecordError (strict by default);
// pass { ignoreUnknownTypes: true } to skip them instead
// consumer updates the Read Model
await updateReadModel(stored);
}
};Isolate poison pills by configuring BisectBatchOnFunctionError, an OnFailure destination, and ReportBatchItemFailures on the consumer side (DEC-013 / DEC-014).
Optional — validate input with Standard Schema
CommandHandler is synchronous, deterministic, and side-effect free. Runtime validation happens at the boundary (outside executeCommand), and the validated value flows in as input (DEC-005 / DEC-010 / DEC-015). minamo does not depend on any validator implementation; instead it ships a validate helper that accepts the Standard Schema v1 interface.
import { type CommandHandler, type InferSchemaOutput, executeCommand, validate } from "@seike460/minamo";
import { z } from "zod"; // Zod v3.24+ / Valibot v1 / ArkType v2 — any Standard Schema-compatible validator
const incrementInputSchema = z.object({ amount: z.number().int() });
type IncrementInput = InferSchemaOutput<typeof incrementInputSchema>;
const handler: CommandHandler<number, CounterEvents, IncrementInput> = (_agg, input) => {
if (input.amount === 0) return [];
return [{ type: "Incremented", data: { amount: input.amount } }];
};
// Validate at the boundary — throws ValidationError on failure, returns a typed value on success
const input = await validate(incrementInputSchema, rawInput);
await executeCommand({ config: counter, store, handler, aggregateId: "counter-1", input });See docs/concept.md §5.9 / DEC-015 for the full specification.
Design Boundaries
What minamo intentionally does not do, and why. Full rationale in docs/concept.md §4 "設計の姿勢" / §6 Non-Goals / §11 Decisions.
- Projection layer is consumer-owned (DEC-013 / DEC-014).
EventStore.append/EventStore.loadis the write-side contract. Stream → Read Model delivery, poison-pill isolation, and append-time projection middleware are out of scope. For sync projection in local/test runtimes use the Decorator recipe inexamples/projected-event-store/; for production use DynamoDB Streams +parseStreamRecord(examples/multi-aggregate-projection/). - Event type naming is not enforced (DEC-009). minamo does not register or validate event type strings. Shared-table deployments should use Aggregate prefixes (
Counter.Incremented,Wallet.Credited) by convention, not by library check. - Aggregate state is plain data, not a framework object (DEC-011). State must be structured-cloneable and DynamoDB-marshallable.
evolvereturns a new value with spread / concat / filter. immer and similar draft libraries can be used inside yourevolveif you prefer, but they are not a dependency and the state contract stays plain. - Ergonomic helpers are thin wrappers, not new contracts (DEC-023). The Aggregate-spanning
createEventStoreTablefacade and the first-partycreateCommandRunnershipped in v0.2.0, but the core write side stays the single-AggregateEventStore<TMap>contract with the object-paramsexecuteCommandsurface —.for<TMap>()narrows to one Aggregate rather than exposing a heterogeneous union.
Design
docs/concept.md— Design philosophy and the canonical public API spec (§5 API Design / §11 Decisions)docs/design/v0.1.0/— Per-unit detailed design (U1–U9)docs/design/v0.1.0.md— Implementation order and module structuredocs/roadmap.md— Shipped v0.2.0 helpers and the remaining backlogdocs/pitfalls.md— Pitfalls and gotchas learned from real production use- API reference — Auto-generated by typedoc, served on GitHub Pages
Examples
Runnable examples under examples/ mirror the code in this README and provide canonical patterns for common use cases:
examples/counter/— Minimal InMemory and DynamoEventStore demos (concept.md §4)examples/multi-aggregate-projection/— Route N Aggregates through one Lambda withparseStreamRecord+eventNamesOf(DEC-009 + DEC-013)examples/projected-event-store/— Consumer Decorator for sync projection and acreateCommandRunnerfactory. Shows how to handle append → projection without adding hooks to the core API (DEC-013 / DEC-014)examples/dynamodb-local/— End-to-endDynamoEventStoreon Docker DynamoDB Local (append / load / rehydrate /ConcurrencyError)examples/upcasting/— Schema evolution viaAggregateConfig.upcast(consumer-owned transform, DEC-020)examples/snapshot/—SnapshotStore+snapshotPolicyto shorten rehydration, withExecuteObservershowing the replay-count reduction (DEC-019 / DEC-021)
The path to v1.0.0 (v1 features shipped in v0.2.0; the remaining gates are non-code) is tracked in
docs/roadmap-v1.md.
License
MIT © Shiro Seike
