Theme 07 · Effect / Rust / OCaml-Style Design
Effect preferences
Explanation
Effect preferences
Plain Human Explanation
An effect is something the code does outside plain calculation: read the clock, call an API, write to a database, send email, log, retry, or throw.
Effect preferences are the team’s rules for where those actions belong. The practical goal is simple: make side effects visible enough that product behavior can be reviewed, tested, and changed without surprises.
This does not require adopting a full effect library. It means deciding when to keep logic pure, when to pass dependencies as ports, when to return a decision first, and when a direct side effect is honest enough.
Technical Explanation
Good TypeScript can borrow from Effect-style design by separating decisions from execution. A pure function decides what should happen. A workflow function performs the database writes, emails, logs, and external calls through explicit dependencies.
The preference is not “never use side effects.” Product software needs side effects. The preference is “make the important side effects visible at the boundary where they matter.”
Why It Matters
- User impact: fewer duplicate emails, hidden retries, silent billing changes, and hard-to-explain outcomes.
- Product behavior: side effects line up with the decision the product promises.
- Risk: hidden calls make tests brittle and incidents hard to trace.
- Decision point: use this when a feature mixes business decisions with database writes, network calls, time, retries, or notifications.
The Core Move
Separate “what should happen” from “perform it,” then pass the effectful tools into the small shell that performs it.
Small Example
Effect preferences: Small Example
Bad TypeScript Example
export async function sendWelcomeEmail(email: string) {
await fetch("https://email.example.com/send", {
method: "POST",
body: JSON.stringify({
to: email,
subject: "Welcome",
sentAt: Date.now(),
}),
});
}
export async function sendWelcomeEmail(
email: string,
) {
await fetch(
"https://email.example.com/send",
{
method: "POST",
body: JSON.stringify({
to: email,
subject: "Welcome",
sentAt: Date.now(),
}),
},
);
}
Good TypeScript Example
type WelcomeEmail = {
to: string;
subject: string;
sentAt: Date;
};
type EmailClient = {
send(message: WelcomeEmail): Promise<void>;
};
type Clock = {
now(): Date;
};
function planWelcomeEmail(email: string, clock: Clock): WelcomeEmail {
return {
to: email,
subject: "Welcome",
sentAt: clock.now(),
};
}
export async function sendWelcomeEmail(email: string, emailClient: EmailClient, clock: Clock) {
const message = planWelcomeEmail(email, clock);
await emailClient.send(message);
}
type WelcomeEmail = {
to: string;
subject: string;
sentAt: Date;
};
type EmailClient = {
send(
message: WelcomeEmail,
): Promise<void>;
};
type Clock = {
now(): Date;
};
function planWelcomeEmail(
email: string,
clock: Clock,
): WelcomeEmail {
return {
to: email,
subject: "Welcome",
sentAt: clock.now(),
};
}
export async function sendWelcomeEmail(
email: string,
emailClient: EmailClient,
clock: Clock,
) {
const message = planWelcomeEmail(
email,
clock,
);
await emailClient.send(message);
}
What Changed
- The bad version hides the network call and clock read inside the function.
- The good version makes email and time explicit dependencies.
- The message can be tested as a plain value before any side effect runs.
Realistic Example
Effect preferences: Realistic Example
This example uses trial expiration. The product decision is separate from the effects: changing access, emailing the user, and logging the outcome.
Bad TypeScript Example
export async function expireTrial(userId: string, db: any, email: any, logger: any) {
const user = await db.users.find(userId);
if (!user || user.plan !== "trial") return;
await db.users.update(userId, { plan: "free", trialExpiredAt: new Date() });
await email.send(user.email, "Your trial ended");
logger.info("trial expired", user);
}
export async function expireTrial(
userId: string,
db: any,
email: any,
logger: any,
) {
const user = await db.users.find(userId);
if (!user || user.plan !== "trial")
return;
await db.users.update(userId, {
plan: "free",
trialExpiredAt: new Date(),
});
await email.send(
user.email,
"Your trial ended",
);
logger.info("trial expired", user);
}
Good TypeScript Example
type User = {
id: string;
email: string;
plan: "trial" | "free" | "paid";
};
type TrialExpirationDecision = { kind: "not-found" } | { kind: "ignore"; reason: "not-on-trial" } | { kind: "expire"; userId: string; email: string; expiredAt: Date };
type TrialPorts = {
users: {
find(userId: string): Promise<User | null>;
markTrialExpired(userId: string, expiredAt: Date): Promise<void>;
};
email: {
send(to: string, subject: string): Promise<void>;
};
log: {
info(event: string, fields: Record<string, string>): void;
};
clock: {
now(): Date;
};
};
function decideTrialExpiration(user: User | null, now: Date): TrialExpirationDecision {
if (!user) return { kind: "not-found" };
if (user.plan !== "trial") return { kind: "ignore", reason: "not-on-trial" };
return { kind: "expire", userId: user.id, email: user.email, expiredAt: now };
}
export async function expireTrial(userId: string, ports: TrialPorts) {
const user = await ports.users.find(userId);
const decision = decideTrialExpiration(user, ports.clock.now());
if (decision.kind !== "expire") return decision;
await ports.users.markTrialExpired(decision.userId, decision.expiredAt);
await ports.email.send(decision.email, "Your trial ended");
ports.log.info("trial.expired", { userId: decision.userId });
return decision;
}
type User = {
id: string;
email: string;
plan: "trial" | "free" | "paid";
};
type TrialExpirationDecision =
| {
kind: "not-found";
}
| {
kind: "ignore";
reason: "not-on-trial";
}
| {
kind: "expire";
userId: string;
email: string;
expiredAt: Date;
};
type TrialPorts = {
users: {
find(
userId: string,
): Promise<User | null>;
markTrialExpired(
userId: string,
expiredAt: Date,
): Promise<void>;
};
email: {
send(
to: string,
subject: string,
): Promise<void>;
};
log: {
info(
event: string,
fields: Record<string, string>,
): void;
};
clock: {
now(): Date;
};
};
function decideTrialExpiration(
user: User | null,
now: Date,
): TrialExpirationDecision {
if (!user)
return {
kind: "not-found",
};
if (user.plan !== "trial")
return {
kind: "ignore",
reason: "not-on-trial",
};
return {
kind: "expire",
userId: user.id,
email: user.email,
expiredAt: now,
};
}
export async function expireTrial(
userId: string,
ports: TrialPorts,
) {
const user =
await ports.users.find(userId);
const decision = decideTrialExpiration(
user,
ports.clock.now(),
);
if (decision.kind !== "expire")
return decision;
await ports.users.markTrialExpired(
decision.userId,
decision.expiredAt,
);
await ports.email.send(
decision.email,
"Your trial ended",
);
ports.log.info("trial.expired", {
userId: decision.userId,
});
return decision;
}
What Changed
- The bad version mixes lookup, product decision, writes, email, time, and logging in one flow.
- The good version makes the decision testable before the side effects run.
- The ports list shows exactly which outside systems this workflow touches.
System Example
Effect preferences: System Example
At system scale, effect preferences keep a workflow honest about what it reads, writes, calls, retries, and reports.
Larger System-Level Bad TypeScript Example
export class InvoiceRunner {
constructor(private db: any, private stripe: any, private email: any, private logger: any) {}
async run(invoiceId: string) {
const invoice = await this.db.invoices.find(invoiceId);
const charge = await this.stripe.charge(invoice.customerId, invoice.totalCents);
await this.db.invoices.update(invoiceId, { status: "paid", chargeId: charge.id });
await this.email.send(invoice.email, "Invoice paid");
this.logger.info("paid invoice", { invoice, charge });
}
}
export class InvoiceRunner {
constructor(
private db: any,
private stripe: any,
private email: any,
private logger: any,
) {}
async run(invoiceId: string) {
const invoice =
await this.db.invoices.find(
invoiceId,
);
const charge = await this.stripe.charge(
invoice.customerId,
invoice.totalCents,
);
await this.db.invoices.update(
invoiceId,
{
status: "paid",
chargeId: charge.id,
},
);
await this.email.send(
invoice.email,
"Invoice paid",
);
this.logger.info("paid invoice", {
invoice,
charge,
});
}
}
Larger System-Level Good TypeScript Example
type Invoice = {
id: string;
customerId: string;
email: string;
totalCents: number;
status: "open" | "paid";
};
type InvoiceEffect = { kind: "skip"; reason: "missing" | "already-paid" } | { kind: "charge"; invoice: Invoice; idempotencyKey: string };
type InvoicePorts = {
invoices: {
find(invoiceId: string): Promise<Invoice | null>;
markPaid(invoiceId: string, chargeId: string): Promise<void>;
};
payments: {
charge(
customerId: string,
cents: number,
idempotencyKey: string,
): Promise<{
id: string;
}>;
};
email: {
send(to: string, subject: string): Promise<void>;
};
log: {
info(event: string, fields: Record<string, string>): void;
};
};
function planInvoiceCharge(invoice: Invoice | null): InvoiceEffect {
if (!invoice) return { kind: "skip", reason: "missing" };
if (invoice.status === "paid") return { kind: "skip", reason: "already-paid" };
return { kind: "charge", invoice, idempotencyKey: `invoice:${invoice.id}` };
}
export async function runInvoiceCharge(invoiceId: string, ports: InvoicePorts) {
const invoice = await ports.invoices.find(invoiceId);
const effect = planInvoiceCharge(invoice);
if (effect.kind === "skip") return effect;
const charge = await ports.payments.charge(effect.invoice.customerId, effect.invoice.totalCents, effect.idempotencyKey);
await ports.invoices.markPaid(effect.invoice.id, charge.id);
await ports.email.send(effect.invoice.email, "Invoice paid");
ports.log.info("invoice.paid", { invoiceId: effect.invoice.id, chargeId: charge.id });
return { kind: "paid", invoiceId: effect.invoice.id, chargeId: charge.id };
}
type Invoice = {
id: string;
customerId: string;
email: string;
totalCents: number;
status: "open" | "paid";
};
type InvoiceEffect =
| {
kind: "skip";
reason: "missing" | "already-paid";
}
| {
kind: "charge";
invoice: Invoice;
idempotencyKey: string;
};
type InvoicePorts = {
invoices: {
find(
invoiceId: string,
): Promise<Invoice | null>;
markPaid(
invoiceId: string,
chargeId: string,
): Promise<void>;
};
payments: {
charge(
customerId: string,
cents: number,
idempotencyKey: string,
): Promise<{
id: string;
}>;
};
email: {
send(
to: string,
subject: string,
): Promise<void>;
};
log: {
info(
event: string,
fields: Record<string, string>,
): void;
};
};
function planInvoiceCharge(
invoice: Invoice | null,
): InvoiceEffect {
if (!invoice)
return {
kind: "skip",
reason: "missing",
};
if (invoice.status === "paid")
return {
kind: "skip",
reason: "already-paid",
};
return {
kind: "charge",
invoice,
idempotencyKey: `invoice:${invoice.id}`,
};
}
export async function runInvoiceCharge(
invoiceId: string,
ports: InvoicePorts,
) {
const invoice =
await ports.invoices.find(invoiceId);
const effect = planInvoiceCharge(invoice);
if (effect.kind === "skip") return effect;
const charge =
await ports.payments.charge(
effect.invoice.customerId,
effect.invoice.totalCents,
effect.idempotencyKey,
);
await ports.invoices.markPaid(
effect.invoice.id,
charge.id,
);
await ports.email.send(
effect.invoice.email,
"Invoice paid",
);
ports.log.info("invoice.paid", {
invoiceId: effect.invoice.id,
chargeId: charge.id,
});
return {
kind: "paid",
invoiceId: effect.invoice.id,
chargeId: charge.id,
};
}
What Changed
- The bad version hides a payment side effect inside a broad runner.
- The good version plans the payment effect and exposes the idempotency key before calling the payment provider.
- The workflow still performs real side effects, but they are visible through named ports.
When To Use It
Effect preferences: When To Use It
Use This When
- A function mixes product decisions with database writes, network calls, time, email, retries, or logging.
- Tests need to assert the decision without sending real side effects.
- Incident review would need to know exactly which external systems were touched.
Avoid This When
- The function is a tiny adapter whose only job is to call one dependency.
- Splitting the decision from execution would make the code harder to follow.
- A full effect library would add more concepts than the team needs for this workflow.
Tradeoffs
Explicit effects add parameters and small types. They pay off when side effects are expensive, user-visible, or hard to undo. For simple glue code, direct calls can be clearer.
Related Concepts
- Rust-like safety comments
- OCaml-ish domain modules
- Errors-as-values thinking
Practice Prompt
Effect preferences: Practice Prompt
Beginner Exercise
Pick one function and list every side effect it performs: database, network, clock, email, log, random value, or thrown error.
Intermediate Exercise
Extract the decision into a plain function. Keep the side effects in a small wrapper that receives its dependencies as ports.
Stretch Exercise
For one workflow with retries or payments, make the idempotency key or retry decision visible in the planned effect before execution.
Reflection Question
Which side effect in this workflow would be most painful if it happened twice or happened silently?
Suggest an edit
Leave a private editorial note. This creates a GitHub issue for this curriculum page.