This document details the exact steps an agent must follow to refactor a complex Effect-based orchestrator or worker into the "Functional Core, Imperative Shell" pattern.
Many workers organically grow into a messy, interleaved mix of:
- I/O & Side Effects:
yield* db.findUnique,yield* fetch(...),yield* Effect.logInfo,yield* create_manual_review_event. - Business Logic: Deeply nested
if/elsestatements, threshold math, and state disqualifiers.
The Goal: Separate the Business Logic (What we should do) from the Orchestration (How we fetch data and apply side effects).
This makes the domain logic 100% unit-testable without mocking Prisma or external APIs, prevents
expensive I/O when early disqualifiers are met, and treats all business outcomes symmetrically
(avoiding the anti-pattern of using Effect.fail for expected domain routing).
For every refactored workflow, you will create or update two distinct areas:
- The Policy File (e.g.,
src/modules/domain/my-policy.ts): 100% pure TypeScript. ZeroEffectgenerators. Contains theActionADT and pure decision functions. - The Imperative Shell (e.g.,
src/workers/my-worker.ts): The Effect orchestrator. Gathers state, calls the pure policy, and applies side effects using$match.
Look at the target Effect.gen block. Identify every possible path that results in a side effect, a
return value, or a logical short-circuit. Define a Data.TaggedEnum that represents these outcomes.
import { Data } from "effect";
// src/modules/orders/fulfillment/my-policy.ts
export type MyDomainAction = Data.TaggedEnum<{
SkipSimulatedMode: {};
FlagMissingData: { field: string };
FlagExternalError: { error: { _tag: string; message: string } };
OverrideToCompleted: { current: number; expected: number };
ConfirmFailed: { current: number; expected: number };
}>;
export const MyDomainAction = Data.taggedEnum<MyDomainAction>();Rule: Do not use the Effect error channel for these outcomes. "Simulated Mode" is a valid business state, not an infrastructure failure.
Identify the data needed to make these decisions. Crucially, separate data that is already known in memory (Early Context) from data that requires expensive I/O (External Context).
export interface EarlyDisqualifierContext {
readonly is_simulated: boolean;
readonly initial_amount: number | null;
}
export interface ExternalStateContext {
readonly initial_amount: number; // Safe because early check passed
readonly ordered_amount: number;
readonly threshold_percent: number;
readonly scrape_result:
| { ok: true; count: number }
| { ok: false; error: { _tag: string; message: string } };
}Write pure functions that take the contexts and return an Action. These functions must NEVER use
yield*, Effect, or perform logging/I/O.
export function check_early_disqualifiers(ctx: EarlyDisqualifierContext): MyDomainAction | null {
if (ctx.is_simulated) return MyDomainAction.SkipSimulatedMode();
if (ctx.initial_amount === null)
return MyDomainAction.FlagMissingData({ field: "initial_amount" });
return null;
}
export function determine_action(ctx: ExternalStateContext): MyDomainAction {
if (!ctx.scrape_result.ok) {
return MyDomainAction.FlagExternalError({ error: ctx.scrape_result.error });
}
const current = ctx.scrape_result.count;
const expected = ctx.initial_amount + ctx.ordered_amount * (ctx.threshold_percent / 100);
if (current >= expected) {
return MyDomainAction.OverrideToCompleted({ current, expected });
}
return MyDomainAction.ConfirmFailed({ current, expected });
}Back in the worker/orchestrator file, create a function that takes the Action ADT and executes the
exact side effects required for that outcome using $match.
// src/workers/my-worker.ts
const apply_action = Effect.fn("my_worker.apply_action")(function* (
order_id: number,
action: MyDomainAction,
) {
return yield* MyDomainAction.$match(action, {
SkipSimulatedMode: () =>
Effect.gen(function* () {
yield* Effect.logInfo("Skipping in simulated mode", { order_id });
return { skipped: true };
}),
FlagMissingData: a =>
Effect.gen(function* () {
yield* create_manual_review_event(order_id, `Missing ${a.field}`);
return { flagged: true };
}),
// ... implement all other branches, moving existing Effect.logs and db.updates here
});
});Replace the messy, interleaved logic with a clean pipeline:
handler: (data, payload) =>
Effect.gen(function* () {
// 1. Gather Early State
const initial_amount = payload.order.initial_amount;
const is_simulated = process.env.SIMULATED_MODE === "true";
// 2. Early Policy Check (Saves expensive I/O!)
const early_action = check_early_disqualifiers({ is_simulated, initial_amount });
if (early_action) {
return yield* apply_action(payload.order.id, early_action);
}
// 3. Gather External State (The expensive I/O)
const scrape_either = yield* Effect.either(get_engagement_count(payload.order));
const config = yield* MyDomainConfig;
// 4. Late Policy Check
const action = determine_action({
initial_amount: initial_amount as number,
ordered_amount: payload.amount,
threshold_percent: config.thresholdPercent,
scrape_result: Either.match(scrape_either, {
onLeft: error => ({ ok: false as const, error }),
onRight: count => ({ ok: true as const, count }),
}),
});
// 5. Apply Action
return yield* apply_action(payload.order.id, action);
});- No I/O or Logging in the Policy: The policy file must be 100% pure TypeScript. If you need to
log a decision, log it inside the
Action.$matchblock in the worker shell. - Be Precise with Context: Do not pass massive Prisma objects (e.g., the entire
fulfillmentrow) into the pure policy functions. Pass only the exact scalar values required to make the decision (e.g.,initial_amount,ordered_amount). - Map
Eitherto Plain Objects for the Policy: Pure functions shouldn't deal withEffect.Eitherif possible. MapEitherresults to simple{ ok: true, data } | { ok: false, error }discriminators before passing them into the policy, keeping the policy decoupled from Effect primitives. - Don't use
Effect.failfor Domain Routing: Use theActionADT to handle expected business outcomes. ReserveEffect.failor typed errors specifically for infrastructural failures (like network timeouts orPrismaError).