Skip to content
Agentmart
Submit a tool

effect-adoption

Adopting Effect in an existing Promise codebase one module at a time.

Skill by andabove

IndexedMIT
Write a review

Install

Works with Claude Code, Claude apps, Codex, Cursor, Gemini CLI, GitHub Copilot, VS Code, OpenCode, Goose, Windsurf. Read the repository before you give any tool access to your data.

npx agentmarket install skill/andabove-effect-adoption

Asks which of your tools to add it to (Claude Code, Codex, Cursor, VS Code, Windsurf, Gemini CLI, Claude Desktop) and shows the change first. See https://agentmart.dev/cli.

Get the weekly diff

The weekly diff: one email a week with new agent tools, notable updates and grade changes, plus security alerts for tools you follow. Double opt-in, one-click unsubscribe.

We use your email only for this newsletter. Privacy

About

Adopting Effect in an existing Promise codebase one module at a time. Use when you migrate an async module to Effect, decide whether a module is worth migrating, put an Effect program behind a plain async function, or review such a migration.

No reviews yet. Installed it? Reviews come from verified installers only. How reviews work

Permissions

What this skill can do and what it asks for, from its SKILL.md. Checked Oct 25, 2026.

Tools
No tools found
Secrets
None asked for
Install scripts
None found

Network hosts it talks to 1

  • codeload.github.com · found in install command

Files it touches

  • ~/.claude/skills/effect-adoption

What's inside

Skill name
effect-adoption
Description
Adopting Effect in an existing Promise codebase one module at a time. Use when you migrate an async module to Effect, decide whether a module is worth migrating, put an Effect program behind a plain async function, or review such a migration.
License
MIT
Instructions
229 lines in SKILL.md · view on GitHub

Files (2)

  • SKILL.md
  • effect-adoption-worked-example.md · references/

SKILL.md (first lines)

# Adopting Effect one module at a time

Move one module at a time. Each migrated module keeps its exported async function, so the change stays inside the module and you can revert it alone. The tests move first and do not change during the rewrite. A deliberate break of the new code then proves that the tests can see each behaviour.

Checked against `effect 4.0.0`. When the project has a newer version, read `node_modules/effect/src/<Module>.ts` and `node_modules/effect/AGENTS.md` before you trust a name here.

## Decide first: where Effect pays

| Module shape | Gain |
|---|---|
| One SDK call, and the SDK already has a retry, a timeout and an abort | Little. In a real migration of five server modules, modules like this grew by 18 to 45 percent in lines. |
| Its own retry policy, parallel calls, a concurrency limit, or resources that need cleanup | The most. Effect replaces hand-written loops, timers and `finally` blocks. |

Judge adoption for the codebase, not for each module alone. A team adopts Effect for one pattern everywhere: typed errors, one way to retry and time out, cancel that reaches every call, cleanup that always runs. If the team adopts that pattern, a thin module moves too, and its growth is the known price. If the team does not, migrate only the modules that gain the most.

## Steps

Do the steps in order. Each step ends on its done line.

1. **Measure before.** Record the four [measures](#measure) for the module. Done when you have four numbers from the old code.
2. **Move the tests to the edge.** Point the tests at the function that production calls, through its public signature. Reach its dependencies through the seams it already has, such as a client parameter, so the signature does not change. Write an abort test that checks the call's own signal (see [cancel](#cancel-reaches-the-call)). Commit the tests while they pass on the old code. Run them on the old code several times, for example five, before you claim they pass. One passing run can hide a flaky test. In one migration, a test committed as passing failed in about 2 runs out of 5. Done when the commit is made and every test passes on the old code in every run.
3. **Rewrite inside the module.** Write the Effect program and the [edge](#the-edge-a-plain-async-function). Change no test file. Done when the unchanged tests pass and `tsc` passes.
4. **Break it on purpose.** Make each break below, run the tests, and revert. Done when each break fails at least one test:
   - drop the signal from the underlying call,
   - change the retry count,
   - retry or catch the wrong fault (for example, retry a 404),
   - remove the already-aborted guard at the edge.

   A break that no test catches is a missing test. Add the test, confirm it fails on the break and passes on the old code, then continue.
5. **Measure after.** Record the four measures again and report both sets. Done when the report holds before and after numbers for each measure.

## The edge: a plain async function

The Effect program stays inside the module. The exported function keeps the old signature, runs the program, and maps the `Exit` back to a value or a thrown error. Callers and tests see no Effect type.

```ts
import * as Cause from "effect/Cause"
import * as Data from "effect/Data"
import * as Effect from "effect/Effect"
import * as Exit from "effect/Exit"
import * as Schedule from "effect/Schedule"

interface Invoice {
  readonly id: string
  readonly total: number
}
interface InvoiceClient {
  get(id: string, options: { readonly signal: AbortSignal }): Promise<Invoice>
}
declare const isTransient: (error: unknown) => boolean

export class InvoiceLoadError extends Data.TaggedError("InvoiceLoadError")<{
  readonly message: string
  readonly cause: unknown
}> {}

const load = (client: InvoiceClient, id: string) =>
  Effect.tryPromise({
    try: (signal) => client.get(id, { signal }),
    catch: (cause) => new InvoiceLoadError({ message: `could not load invoice ${id}`, cause })
  }).pipe(
    Effect.retry({
      times: 2,
      schedule: Schedule.spaced("100 millis"),
      while: (error) => isTransient(error.cause)
    })
  )

export async function loadInvoice(
  client: InvoiceClient,
  id: string,
  options: { readonly signal?: AbortSignal } = {}
): Promise<Invoice> {
  options.signal?.throwIfAborted()
  const exit = await Effect.runPromiseExit(load(client, id), { signal: options.signal })
  if (Exit.isSuccess(exit)) return exit.value
  if (Cause.hasInterruptsOnly(exit.cause) && options.signal?.aborted) throw options.signal.reason
  throw Cause.squash(exit.cause)
}
```

- **Run with `Effect.runPromiseExit(program, { signal })`.** The signal interrupts the fiber when the caller aborts. `runPromiseExit` never rejects, so the edge decides what to throw.
- **Tell a cancel from a fault with `Cause.hasInterruptsOnly(exit.cause)`.** It is `true` only when every reason is an interrupt. Throw the signal's `reason`, so the caller gets back the value it passed to `abort()`.
- **Throw `Cause.squash(exit.cause)` for a fault.** It gives the typed error for a failure and the thrown value for a defect: one error to log. It drops the other reasons in the cause; use `Cause.pretty` when the log needs them all.
- **Keep the error contract the tests assert.** Here the old code threw `InvoiceLoadError` with the fault as `cause`. The tagged error keeps the same `name`, `message` and `cause`, so the old assertions still pass.
- **Keep the program unexported** until a caller also moves to Effect. Then that caller can import the program and compose it, and the edge stays for the remaining Promise callers.
- **Build a layer once, not per call.** When the program needs services, build them once with `ManagedRuntime.make(layer)` at module level and call `runtime.runPromiseExit(program, { signal })`. `Effect.provide(layer)` inside the edge builds the layer again on every call. See `skill:effect-services`.
- **Give that runtime an owner that disposes it.** The runtime holds its resources until `await runtime.dispose()` runs: no release happens before it. Name the code that owns the runtime, and call `dispose()` in the application's shutdown hook and in test teardown (`afterAll`). A run started after `dispose()` fails.

The full module before and after, with its test file and the four breaks, is in [references/effect-adoption-worked-example.md](references/effect-adoption-worked-example.md).

## Cancel reaches the call

Pass the signal that `Effect.tryPromise` gives the thunk to the underlying call: `try: (signal) => client.get(id, { signal })`. When the fiber is interrupted, `runPromiseExit` returns at once and does not wait for the call. A call that did not get the signal runs on to the end, and the code still compiles.

- **Assert the call's own signal in every abort test.** A fake that records the `signal` it receives lets the test check `recorded.aborted === true`. A test that checks only the rejection passes with the signal dropped, because the edge returns at once either way.
- **Declare the parameter in the thunk itself.** Effect makes the `AbortController` only when the thunk's `length` is above 0. A thunk written as `(...args) =>` or `(signal = fallback) =>` has length 0 and gets no fiber signal, though its type says `AbortSignal`.
- **Use `Effect.promise((signal) => ...)` the same way** for a call that cannot reject.

## A signal that is already aborted

`runPromiseExit` runs the program up to its first async step (or until the runtime yields, after a long run of sync steps) and only then reads the signal. So with a signal that is already aborted:

- a program that starts with the call starts the call, with a signal that is not yet aborted, then aborts it;
- a program with sync steps first runs them, then starts the call the same way;
- a program with an async step before the call does not start the call;
- a program with no async step completes and returns its value.

Do not promise that no call starts. If the old function guaranteed it, keep the guard `options.signal?.throwIfAborted()` at the top of the edge, as in the example, and keep a test for it.

## Errors keep their cause

Wrap each fault in a `Data.TaggedError` with a `cause` field that holds the original fault. `Data.TaggedError` passes `cause` and `message` to the native `Error` constructor, so `error.cause` is the original fault, and log sanitisers and error reporters that walk the `cause` chain still name the real fault.

- Give the error a `message` field. Without one, `error.message` is the empty string.
- The error's `name` is its tag, so assertions on `name` keep working when the tag is the old class name.
- Name the field `cause`. Only that name reaches the native `Error.cause` that reporters walk.

For tagged errors, `catchTag`, and retry policy as error handling, see `skill:effect-errors`.

From andabove/skills, MIT.

More from andabove/skills

Signals

Stars
0
Forks
0
Open issues
0
Last commit (this folder)
Oct 3, 2026 (26 days ago)

From GitHub (fetched Oct 24, 2026).

Details

License
MIT
First seen
Oct 24, 2026
Last checked
Oct 24, 2026

More from andabove

Cite this page

effect-adoption skill. Agent Mart. https://agentmart.dev/skills/andabove-effect-adoption (last updated 2026-10-24)