Skip to content
Back to skills

Functional Making Effects Reliable

ASecurity

Use when a timeout leaves an effect unsure, when a retry could charge twice, when a saved change is published separately, or when spawned background work outlives its request.

  • 2 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added October 21, 2026
ai-agentsgoshelldatabase

Works with

  • cli

Security analysis

A100/100

Pro scans all 5 files and shows the line behind each finding

Scanned October 21, 2026

npx -y skills add yutna/functional-design-skills --skill functional-making-effects-reliable --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Functional Making Effects Reliable?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Functional Making Effects Reliable
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/yutna-functional-making-effects-reliable/badge)](https://www.skillsdirectory.com/skills/yutna-functional-making-effects-reliable)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: functional-making-effects-reliable
description: Use when a timeout leaves an effect unsure, when a retry could charge twice, when a saved change is published separately, or when spawned background work outlives its request.
license: MIT
metadata:
  pack: functional-design-skills
  version: 3.3.0
---

# Making Effects Reliable

**In this pack:** [how much design a task warrants](../functional-design/references/calibration.md),
[the notation](../functional-design/references/notation.md), and
[syntax per language](../functional-design/SKILL.md#language-packs).

## Overview

A pure core is safe to run twice. An effect is not: a charge that runs
twice is two charges, and a call that times out may have succeeded. Once
a workflow reaches a second system, the failure modes stop being about
correctness of logic and start being about delivery, duplication, and
partial completion.

The rules below all follow from one idea: keep the decision pure, so
that deciding again does nothing, and make each effect separately
identifiable, so that repeating it is safe and losing it is detectable.

## When to use

- A workflow calls a payment provider, a queue, or another service
- An operation must not happen twice, and retries exist
- A timeout leaves you unsure whether the effect happened
- Events must reach a consumer, and the database write must also happen
- Deciding where retries, timeouts, and compensation live
- A process spans minutes or days and can fail halfway

Not for: choosing transaction boundaries, which is
[functional-enforcing-consistency-boundaries](../functional-enforcing-consistency-boundaries/SKILL.md).
This skill and
[functional-managing-state-immutably](../functional-managing-state-immutably/SKILL.md)
meet at work that outlives its request: rule 8 states what it needs, and
that skill covers the in-process case in full.

## Core rules

1. Rule. **Give every command whose effects cross a process boundary an
   identity.** A client-supplied key, carried through the workflow, is
   what makes de-duplication possible at all. A command whose whole
   effect is one transaction in one database is outside this rule: where
   a repeat of it would matter, a unique constraint in that transaction
   is enough.
2. Rule. **Make the effect idempotent, not the decision.** The pure core is
   already safe to repeat; the write, the charge, and the send are not.
   Idempotency belongs in the adapter.
3. Rule. **Assume at-least-once, everywhere.** Exactly-once delivery does not
   exist across a network. De-duplicate on receipt instead.
4. Rule. **Never write and publish in two places.** Either the event is recorded
   in the same transaction as the state change and relayed afterwards, or a
   consumer will eventually see one without the other. A notice that carries
   nothing a consumer keeps, such as a hint to a screen to read again, is
   outside this rule: losing it loses nothing that is stored.
5. Default. **Retry at the shell, bounded.** A domain step that retries hides
   latency and cannot be tested in time. Bound attempts, bound total time, and
   add jitter. Retry only a transient fault, and only a read or a write that is
   idempotent on its key. A call that has to outlive its request is not retried
   in place: it is an instruction in an outbox, and the queue retries it.
6. Default. **Compensate; do not lock across systems.** A distributed
   transaction is a lock held over a network. Undo explicitly instead, and
   accept that the undo is a business operation with a business name. This is
   for steps that span systems: steps in one database are one transaction, and
   [compensating-workflows.md](references/compensating-workflows.md) lists the
   other answers that cost less than a process manager.
7. Default. **Model partial success explicitly.** "Three of five sent" is not a
   `Result`. It is a report type with both lists inside it. This is for
   independent items, each of which has taken effect by itself. The steps of one
   process that must all happen or all be undone belong to rule 6, and are
   compensated.
8. Rule. **Work that outlives its command has an owner or a cancel.**
   Something started in the background either has a caller that waits
   for it or a handle that stops it. Work with neither cannot be
   drained at shutdown, cannot be stopped when the request that wanted
   it has gone, and its failures arrive with nothing to attach them to.
   See
   [concurrency.md](../functional-managing-state-immutably/references/concurrency.md).

## Pattern

Unreliable: three effects, three failure points, no identity.

```text
-- one error type, StepError, all the way along
bookPaidAppointment request =
  charge request.card request.amount   -- may time out after succeeding
    |> bind (saveBooking request)      -- may fail after the charge
    |> bind publishBookingConfirmed    -- may be lost entirely
```

A timeout on `charge` leaves nobody knowing whether money moved. A
process that dies between `saveBooking` and `publishBookingConfirmed`
loses the event silently, and a retry of the whole workflow charges
again.

Reliable: an identity, an idempotent adapter for each effect, and one
transaction.

```text
type CommandId = CommandId of String
type BookingRequest = { key: CommandId, ... }   -- supplied by the caller

-- each effect gets a key of its own, derived from the command's key
type EffectKey = EffectKey of String
effectKey : CommandId -> String -> EffectKey
effectKey (CommandId key) operation = EffectKey (join ":" [operation, key])

-- every adapter takes its effect key and is idempotent on it
charge :
  EffectKey -> CardToken -> Money -> AsyncResult<Receipt, ChargeError>

-- state and outbox rows written together, relayed afterwards; the key
-- is the unique key of the outbox rows
saveBookingAndEvents :
  EffectKey -> Booking -> List<BookingEvent>
    -> AsyncResult<Unit, StoreError>
```

The shell calls `effectKey key "charge"` for the charge and
`effectKey key "save"` for the save. One key shared by every effect is
wrong: a provider that honours idempotency keys treats a second call
with the same key as a replay of the first, and answers it with the
first call's result or an error. A refund must not come back as the
charge's receipt.

Retrying the workflow with the same `key` is now safe: the charge
returns the original receipt, the save is a no-op, and the event is
relayed again as often as the relay fails before marking it sent, which
the consumer de-duplicates.

## Delivery guarantees

| Guarantee     | What it costs                           |
| ------------- | --------------------------------------- |
| At most once  | Silent loss; only for data you can lose |
| At least once | Duplicates; consumers must de-duplicate |
| Exactly once  | Does not exist across a network         |

What is sold as exactly-once is always at-least-once delivery plus
de-duplication somewhere. Decide where that somewhere is, and write it
down beside the consumer.

## Where each concern lives

| Concern           | Layer                                    |
| ----------------- | ---------------------------------------- |
| Command identity  | The caller, before its first attempt     |
| Idempotency       | The adapter that performs the effect     |
| Retry and timeout | The shell, bounded                       |
| De-duplication    | The consumer, on receipt                 |
| Compensation      | A named domain operation                 |
| Ordering          | One writer, or a sequence number         |
| Backpressure      | The shell, from queue depth              |

The command's key is the one identifier the shell does not generate: a
key made on the server changes on every retry. The shell performs
every effect behind that table and bounds every wait. What an outcome
leads to is decided in the core: `advance`, the transition function of
the process, says when to compensate and what follows a wait that ran
out (see
[compensating-workflows.md](references/compensating-workflows.md)). The
core stays a function from values to a decision. See
[functional-separating-pure-core-from-shell](../functional-separating-pure-core-from-shell/SKILL.md).

## Red flags

- A command with no identifier the caller controls
- A state change and a publish in two separate transactions
- Retry logic inside a domain function
- An unbounded retry loop, or retries with no jitter
- A non-idempotent operation behind a retry
- A workflow that reports success when half its effects ran
- Compensation implemented as "delete the row"
- A queue with no dead-letter destination

## Common mistakes

- **Retrying a non-idempotent effect.** The retry is the bug, not the
  network.
- **Relying on exactly-once from a broker.** It is at-least-once with
  extra steps; de-duplicate anyway.
- **Making the domain function idempotent instead of the adapter.** The
  domain is already pure; the effect is what repeats.
- **Using a distributed transaction.** It holds a lock across a network
  and fails in ways nobody can reason about. Compensate instead.
- **Compensating by deleting.** The undo is a business event with its own
  name and its own record: refunded, cancelled, released.
- **Treating partial success as failure.** Rolling back four successful
  sends because the fifth failed is usually worse than reporting both.

## Related skills

- [functional-separating-pure-core-from-shell](../functional-separating-pure-core-from-shell/SKILL.md)
- [functional-enforcing-consistency-boundaries](../functional-enforcing-consistency-boundaries/SKILL.md)
- [functional-modeling-state-machines](../functional-modeling-state-machines/SKILL.md)
- [functional-designing-what-to-observe](../functional-designing-what-to-observe/SKILL.md)

## Further reading

- [idempotency-and-delivery.md](references/idempotency-and-delivery.md)
  covers command identity, the outbox, the queues that can carry it, and
  de-duplication.
- [compensating-workflows.md](references/compensating-workflows.md)
  covers process managers, compensation, and when a saga is wrong.
- [retries-and-backpressure.md](references/retries-and-backpressure.md)
  covers retry budgets, circuit breaking, and load shedding.
- [worked-long-running.md](references/worked-long-running.md) builds a
  three-system booking process end to end.

Files in this skill

  • SKILL.md10.1 KB
  • references/compensating-workflows.md5.7 KB
  • references/idempotency-and-delivery.md7.4 KB
  • references/retries-and-backpressure.md6.6 KB
  • references/worked-long-running.md25.7 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.