Effect
Sources: docs-original/GLOSSARY.md, packages/core/docs/FDR.md, packages/core/docs/SPEC.md Status: Core Concept
What is Effect?
Definition: A declaration of an external operation that Host must execute. Effects are not executed by Core; they are declarations of intent to perform IO.
Canonical Principle:
Core declares requirements. Host fulfills them. Core never executes IO.
Effect vs Side Effect
Traditional "side effect":
// Something that "just happens" during execution
function saveUser(user) {
database.save(user); // Side effect!
return user;
}Manifesto Effect:
// Explicit declaration
{
kind: "effect",
type: "database:save",
params: { table: "users", data: user }
}
// Not executed here! Just declared.Structure
In FlowSpec
type EffectNode = {
readonly kind: 'effect';
readonly type: string; // Handler identifier
readonly params: Record<string, ExprNode>; // Parameters (expressions)
};Example:
{
"kind": "effect",
"type": "api:createTodo",
"params": {
"title": { "kind": "get", "path": "input.title" },
"localId": { "kind": "get", "path": "input.localId" }
}
}As Requirement
When Core encounters an effect node, it creates a Requirement:
type Requirement = {
readonly id: string; // Deterministic ID
readonly type: string; // Effect type
readonly params: Record<string, unknown>; // Resolved parameters
readonly actionId: string; // Which action generated this
readonly flowPosition: FlowPosition; // Where in the flow
readonly createdAt: number; // When
};How Effects Work
Step 1: Declaration (Core)
// In Flow definition
{
kind: "seq",
steps: [
{ kind: "patch", op: "set", path: "loading", value: true },
{
kind: "effect",
type: "api:fetch",
params: {
url: { kind: "lit", value: "/api/todos" }
}
}
]
}Step 2: Recording (Core)
When Core evaluates the effect node:
- Evaluates
paramsexpressions - Creates Requirement with resolved params
- Adds to
snapshot.system.pendingRequirements - Terminates computation (returns
status: 'pending')
Critical: Core does NOT execute the effect. It stops here.
Step 3: Execution (Host)
Host loop:
const context = { now: 0, randomSeed: "seed" };
const result = await core.compute(schema, snapshot, intent, context);
if (result.status === 'pending') {
// Execute effects
for (const req of result.requirements) {
const handler = effectHandlers[req.type];
const patches = await handler(req.type, req.params, {
snapshot,
requirement: req,
});
// Apply result patches
snapshot = core.apply(schema, snapshot, patches, context);
}
// Clear requirements
snapshot = core.apply(schema, snapshot, [
{ op: 'set', path: 'system.pendingRequirements', value: [] }
], context);
// Re-compute
const nextResult = await core.compute(schema, snapshot, intent, context);
// Flow continues...
}Step 4: Continuation (Core, again)
// Flow continues from where it left off
{
kind: "seq",
steps: [
{ kind: "patch", op: "set", path: "loading", value: true },
{ kind: "effect", type: "api:fetch", params: {...} },
// ↑ Already executed
// ↓ Continues here
{ kind: "patch", op: "set", path: "loading", value: false }
]
}How does Flow know to continue? It checks Snapshot state. If loading = true and data is loaded, it knows the effect completed.
Effect Handler Contract
Effect handlers (implemented by Host) MUST:
- Accept
(type: string, params: Record<string, unknown>, context: EffectContext) - Return
Patch[](success case) orPatch[]with error info (failure case) - Never throw. Errors are expressed as Patches.
Example Handler
// Host-side effect handler
type EffectContext = {
snapshot: Readonly<Snapshot>;
requirement: Requirement;
};
async function apiCreateTodoHandler(
type: string,
params: Record<string, unknown>,
context: EffectContext
): Promise<Patch[]> {
const { snapshot } = context;
try {
const result = await api.createTodo({
title: params.title as string,
localId: params.localId as string
});
return [
{
op: 'set',
path: `todos.${params.localId}.serverId`,
value: result.id
},
{
op: 'set',
path: `todos.${params.localId}.syncStatus`,
value: 'synced'
}
];
} catch (error) {
return [
{
op: 'set',
path: `todos.${params.localId}.syncStatus`,
value: 'error'
},
{
op: 'set',
path: `todos.${params.localId}.errorMessage`,
value: error.message
}
];
}
}Key points:
- No
throw- all outcomes are patches - Success writes result to Snapshot
- Failure writes error info to Snapshot
- Next
compute()sees the result in Snapshot
Effects Do NOT Return Values
This is critical and often misunderstood.
Wrong Mental Model
// WRONG: Effect "returning" a value
const result = await executeEffect('api:fetch');
if (result.ok) {
// Use result.data...
}Correct Mental Model
// RIGHT: Effect returns patches
const patches = await executeEffect('api:fetch');
// patches = [
// { op: 'set', path: 'apiResult', value: {...} }
// ]
const context = { now: 0, randomSeed: "seed" };
snapshot = core.apply(schema, snapshot, patches, context);
// Now snapshot.data.apiResult contains the result
// Next compute() reads from Snapshot
const nextResult = await core.compute(schema, snapshot, intent, context);
// Flow can now check snapshot.data.apiResultWhy Effects as Declarations?
From FDR-004:
Alternatives Rejected
| Alternative | Why Rejected |
|---|---|
| Direct Execution | Core executes effects |
| Effect Handlers in Core | Core calls injected handlers |
| Promise-based | Effects return Promises |
Benefits of Declarations
- Purity: Core remains pure; no IO inside
- Testability: Test Flow without executing real effects
- Flexibility: Host decides how/when/whether to execute
- Batching: Host can batch multiple effects
- Retry Logic: Host can implement retry without Core knowing
Effect Handler Best Practices
1. No Domain Logic in Handlers
// WRONG: Domain logic in handler
async function handler(type, params, context) {
if (params.amount > 1000) { // Business rule!
return [{ op: 'set', path: 'approval.required', value: true }];
}
// ...
}Why wrong: Domain logic must be traceable. If it's in the handler, Trace doesn't show it.
// RIGHT: Domain logic in Flow
{
kind: "if",
cond: { kind: "gt", left: { kind: "get", path: "order.amount" }, right: 1000 },
then: { kind: "patch", op: "set", path: "approval.required", value: true },
else: { kind: "effect", type: "payment:process", params: {...} }
}
// Handler just does IO
async function handler(type, params, context) {
await paymentGateway.charge(params.amount);
return [{ op: 'set', path: 'payment.status', value: 'completed' }];
}2. Always Return Patches for Errors
// WRONG
async function handler(type, params, context) {
const response = await fetch(params.url);
if (!response.ok) throw new Error('Failed'); // WRONG!
}
// RIGHT
async function handler(type, params, context) {
try {
const response = await fetch(params.url);
if (!response.ok) {
return [
{ op: 'set', path: 'status', value: 'error' },
{ op: 'set', path: 'errorMessage', value: `HTTP ${response.status}` }
];
}
// ...
} catch (error) {
return [
{ op: 'set', path: 'status', value: 'error' },
{ op: 'set', path: 'errorMessage', value: error.message }
];
}
}3. Idempotent When Possible
// Good: Idempotent handler
async function createUserHandler(type, params) {
// Check if user already exists
const existing = await db.users.findOne({ id: params.id });
if (existing) {
// Already exists, just return
return [
{ op: 'set', path: `users.${params.id}`, value: existing }
];
}
// Create new
const user = await db.users.create(params);
return [
{ op: 'set', path: `users.${params.id}`, value: user }
];
}Common Pitfalls
Pitfall 1: Expecting Effect to Execute in Flow
// WRONG expectation
{
kind: "seq",
steps: [
{ kind: "effect", type: "api:fetch", params: {} },
// Expecting result to be available immediately
{ kind: "patch", op: "set", path: "processed",
value: { kind: "get", path: "api.result" } } // Will be undefined!
]
}Fix: Flow must be re-entry safe. Check if result exists before using it.
// RIGHT
{
kind: "seq",
steps: [
{ kind: "effect", type: "api:fetch", params: {} },
// After Host executes effect and re-computes...
{ kind: "if",
cond: { kind: "isSet", arg: { kind: "get", path: "api.result" } },
then: {
kind: "patch", op: "set", path: "processed",
value: { kind: "get", path: "api.result" }
}
}
]
}Pitfall 2: Not Clearing Requirements
// WRONG: Host forgets to clear
const context = { now: 0, randomSeed: "seed" };
const result = await core.compute(schema, snapshot, intent, context);
for (const req of result.requirements) {
const patches = await executeEffect(req);
snapshot = core.apply(schema, snapshot, patches, context);
}
// Missing: clear pendingRequirements!
await core.compute(schema, snapshot, intent, context); // Infinite loop!Fix: Always clear after execution.
// RIGHT
const context = { now: 0, randomSeed: "seed" };
const result = await core.compute(schema, snapshot, intent, context);
for (const req of result.requirements) {
const patches = await executeEffect(req);
snapshot = core.apply(schema, snapshot, patches, context);
}
// Clear requirements
snapshot = core.apply(schema, snapshot, [
{ op: 'set', path: 'system.pendingRequirements', value: [] }
], context);
await core.compute(schema, snapshot, intent, context);Related Concepts
- Requirement - The pending effect stored in Snapshot
- Host - The layer that executes effects
- Patch - What effects return
- Flow - Where effects are declared
See Also
- Schema Specification - Normative specification including EffectNode
- Core FDR - Design rationale including Effects as Declarations
- Effect Handlers Guide - Practical guide
- Host - The execution layer