Domain-driven design for TypeScript AI agents
Clone → npm install → npm run dev → REST API and business-logic aggregates.
domain-driven-agent is a TypeScript library that applies Domain-Driven Design (DDD) ideas to agents and automation: bounded contexts, aggregates, value objects, domain events, and (optional) event sourcing for replay and audit.
Instead of treating behavior as a single prompt plus loose tools, you model commands → aggregate → domain events → event store, with type-safe value objects and explicit invariants.
Good fits
- Enterprise automation (orders, approvals, ticketing)
- Financial or compliance workflows that need an audit trail
- Any system where rules and state must stay consistent as complexity grows
| Feature | Description |
|---|---|
| Aggregate agents | AggregateAgent applies events and tracks uncommitted work. |
| Value objects | Immutable, validated types (OrderId, Money, …). |
| Domain events | Typed events with occurredAt persisted for replay. |
| Event sourcing | SQLite EventStore: load by replaying history; optimistic concurrency on save. |
| Sagas / projectors | Saga (orchestration hooks) and Projector (read-model style handlers). |
| REST API | Commands and queries over HTTP (see below). |
PostgreSQL / EventStoreDB backends are left as extension points (connectionString is reserved on EventStoreOptions).
- Node.js 22+
- npm (or compatible client)
cd domain-driven-agent
npm installnpm run devThe server listens on port 3000 by default (PORT). The SQLite database path defaults to ./events.db (EVENT_STORE_DB_PATH). See .env.example.
Health
curl http://localhost:3000/healthCreate order — orderId must match ORD- plus nine digits (e.g. ORD-123456789). In JSON, amount must be a number (not a string).
curl -X POST http://localhost:3000/orders/ORD-123456789/commands/create \
-H "Content-Type: application/json" \
-d '{"customerId":"CUST-42","amount":99.99,"currency":"USD"}'Approve
curl -X POST http://localhost:3000/orders/ORD-123456789/commands/approve \
-H "Content-Type: application/json" \
-d '{"approvedBy":"manager@example.com"}'Record payment failure
curl -X POST http://localhost:3000/orders/ORD-123456789/commands/fail-payment \
-H "Content-Type: application/json" \
-d '{"reason":"card declined"}'Read state
curl http://localhost:3000/orders/ORD-123456789/statestatus is none until an order is created, then pending, approved, or failed.
Order invariants (example aggregate) — approve and fail-payment apply only while status is pending (not after approval or failure).
From this repository (run with tsx or compile with npm run build and run from dist/):
import { EventStore, OrderAgent, OrderId, Money } from "./src/index.js";
const store = new EventStore({ dbPath: "./events.db" });
const orderId = OrderId.create("ORD-123456789");
const agent = await store.load(orderId.value, () => new OrderAgent(orderId));
agent.createOrder("CUST-42", new Money({ amount: 99.99, currency: "USD" }));
agent.approveOrder("manager@example.com");
await store.save(agent);
const again = await store.load(orderId.value, () => new OrderAgent(orderId));
console.log(again.status); // "approved"If you depend on this package by name elsewhere, import from domain-driven-agent (after npm run build), matching the main in package.json.
┌─────────────────────────────────────────────────────────────┐
│ Domain: value objects, aggregates, domain events │
└────────────────────────────┬────────────────────────────────┘
│
┌────────────────────────────┼────────────────────────────────┐
│ Application: sagas, projectors, repositories │
└────────────────────────────┬────────────────────────────────┘
│
┌────────────────────────────┼────────────────────────────────┐
│ Infrastructure: EventStore (SQLite), HTTP API │
└────────────────────────────┴────────────────────────────────┘
Flow: Command → aggregate → domain event(s) → EventStore
Abstract base with equals() and toJSON() so nested value objects persist correctly.
interface DomainEvent {
readonly type: string;
readonly occurredAt?: Date;
}get id(): stringgetUncommittedEvents()/clearEvents()loadFromHistory(events)— replay without duplicating uncommitted eventsprotected apply(event)— applieswhen, increments version, queues uncommitted events unless replaying
Subclasses implement protected when(event) (see OrderAgent) to route to private on<EventType> handlers.
new EventStore({ dbPath?: string; connectionString?: string });
await store.load(aggregateId, () => new OrderAgent(orderId));
await store.save(agent);
await store.getEvents(aggregateId);Saves enforce optimistic concurrency: if the persisted stream advanced since load, save throws.
import { Saga, step, type SagaContext } from "./src/framework/Saga.js";
import type { OrderCreated } from "./src/domain/order/events.js";
const ctx: SagaContext = {
command: async (agentType, commandName, payload) => {
/* wire to your application / bus */
},
};
const saga = new Saga("order-fulfillment").on(
"OrderCreated",
step(async (event, c) => {
const e = event as OrderCreated;
await c.command("PaymentAgent", "chargeCustomer", { orderId: e.orderId });
}),
);
await saga.dispatch(domainEvent, ctx);import { Projector } from "./src/framework/Projector.js";
const projector = new Projector("order-projector").on("OrderApproved", async (event) => {
/* update read model */
});
await projector.project(event);domain-driven-agent/
├── src/
│ ├── framework/ # ValueObject, AggregateAgent, EventStore, Saga, Projector
│ ├── domain/order/ # Example aggregate + events + value objects
│ ├── infrastructure/ # Repositories
│ ├── api/createApp.ts # Express app factory
│ ├── server.ts # HTTP entry (production / dev)
│ └── index.ts # Library exports
├── tests/
│ ├── framework.test.ts # Saga + Projector
│ ├── integration/ # Event store
│ ├── order.test.ts
│ └── api.test.ts
├── Dockerfile
├── docker-compose.yml
├── .env.example
├── package.json
├── tsconfig.json
└── vitest.config.ts
Build and run the API with a persisted SQLite volume:
docker compose up --buildThe app listens on 3000 and stores events under /data/events.db in the container.
| Script | Purpose |
|---|---|
npm run dev |
tsx watch on src/server.ts |
npm run build |
tsc → dist/ |
npm start |
node dist/server.js (run npm run build first) |
npm test |
Vitest — unit + API + integration |
npm run test:integration |
Event store integration tests |
npm run test:saga |
Saga tests (tests/framework.test.ts, name filter Saga) |
import { OrderAgent, OrderId, Money } from "./src/domain/order/index.js";
const order = new OrderAgent(OrderId.create("ORD-123456789"));
order.createOrder("CUST-1", new Money({ amount: 100, currency: "USD" }));
order.approveOrder("manager");
// order.status === "approved"
// order.getUncommittedEvents().length === 2- Fork / branch
- Add or extend domain tests for new aggregates and invariants
- Run
npm testandnpm run buildbefore submitting changes
MIT