Architecture Patterns
Ryte is designed around a clean separation between domain logic and IO. Handlers are pure decision-makers — they inspect state, validate business rules, and declare what should change. Side effects happen outside the dispatch pipeline.
The IO / Domain / IO Pattern
A well-structured Ryte integration follows three phases:
IO (read) → Domain (dispatch) → IO (write)- IO in: Load the workflow from storage, parse the incoming command
- Domain: Dispatch the command — pure logic, no IO
- IO out: Persist the updated workflow, publish events, send notifications
// 1. IO in — load state
const snapshot = await db.get(workflowId);
const restored = definition.deserialize(snapshot);
if (!restored.ok) throw new Error("Invalid workflow");
// 2. Domain — pure logic, no side effects
const result = await router.dispatch(restored.workflow, command);
// 3. IO out — persist + publish
if (result.ok) {
await db.transaction(async (tx) => {
await tx.set(workflowId, definition.serialize(result.workflow));
for (const event of result.events) {
await tx.publish("workflow-events", event);
}
});
}The key insight: handlers never touch the database, send emails, or call external services directly. They emit events that describe what happened. The IO layer at the end decides how to act on those events.
Why This Matters
Handlers Stay Pure
Handlers that perform IO are hard to test, hard to reason about, and fragile. When a handler calls a payment API inside dispatch(), you can't test the state transition without mocking the payment service. When the payment service is down, your state machine breaks.
// Bad: IO inside the handler
const badRouter = new WorkflowRouter(orderWorkflow);
badRouter.state("Pending", ({ on }) => {
on("PlaceOrder", async ({ data, error, transition }) => {
const charge = await paymentService.charge(data.total); // IO in handler
if (!charge.ok) return error({ code: "PaymentFailed", data: {} });
transition("Placed", { total: data.total, sku: data.sku });
});
});
// Good: handler emits intent, IO layer acts on it
const goodRouter = new WorkflowRouter(orderWorkflow);
goodRouter.state("Pending", ({ on }) => {
on("PlaceOrder", ({ data, transition, emit, workflow }) => {
transition("Placed", { total: data.total, sku: data.sku });
emit({ type: "OrderPlaced", data: { orderId: workflow.id, total: data.total } });
});
});Transactional Consistency
When IO happens after dispatch, you can wrap everything in a transaction. Either the workflow state update AND the event publishing both succeed, or neither does. This is impossible when IO is scattered through handlers.
Events as the Integration Boundary
Events are the contract between your domain logic and the outside world. They're schema-validated, typed, and accumulated per dispatch. This makes them perfect for:
- Event sourcing — store events as the source of truth
- Message queues — publish events to Kafka, RabbitMQ, etc.
- Notifications — trigger emails, webhooks, push notifications
- Audit logs — record what happened and why
Dependency Injection for Reads
Sometimes handlers need to read external data to make decisions (e.g., check inventory before placing an order). Use dependency injection for this — pass read-only services via deps:
type Deps = {
inventory: { check: (sku: string) => Promise<boolean> };
};
declare const deps: Deps;
const depsRouter = new WorkflowRouter(orderWorkflow, deps);
depsRouter.state("Pending", ({ on }) => {
on("PlaceOrder", async ({ deps, data, error, transition, emit, workflow }) => {
const inStock = await deps.inventory.check(data.sku);
if (!inStock) {
return error({ code: "OutOfStock", data: { sku: data.sku } });
}
transition("Placed", { total: data.total, sku: data.sku });
emit({ type: "OrderPlaced", data: { orderId: workflow.id, total: data.total } });
});
});This is acceptable — reads are side-effect-free and easy to stub in tests. The rule is: reads via deps, writes via events.
Summary
| Concern | Where | How |
|---|---|---|
| Load workflow | Before dispatch | restore() from storage |
| Business rules | Inside handler | transition(), error(), emit() |
| Read external data | Inside handler | Via deps (injected, stubbable) |
| Persist state | After dispatch | snapshot() to storage |
| Side effects | After dispatch | Process result.events |
| Notifications | After dispatch | React to events |