03 Effect preferences 6 chapters

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.

  • 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.