Use when business logic cannot be tested without I/O, when one call deep inside forces every caller to become async, or when deciding where a database or HTTP call belongs.
Installs into .claude/skills of the current project.
Are you the author of Functional Separating Pure Core From Shell?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/yutna-functional-separating-pure-core-from-shell)
---
name: functional-separating-pure-core-from-shell
description: Use when business logic cannot be tested without I/O, when one call deep inside forces every caller to become async, or when deciding where a database or HTTP call belongs.
license: MIT
metadata:
pack: functional-design-skills
version: 3.3.0
---
# Separating Pure Core From Shell
**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 functional application has two parts. The **core** is pure: it computes
decisions from values, always returning the same output for the same
input, touching nothing outside itself. The **shell** is everything
else: it reads, writes, waits, and talks to the world, and it holds no
business rule.
One kind of shell function joins the two. A **handler** carries out one
command: it reads what the core needs, calls the core with those values,
and writes what the core decided.
Every property that makes functional code worth writing comes from this
split. A pure core is testable without infrastructure, safe to run
concurrently, easy to reason about, and cheap to reuse. The shell stays
small, boring, and mostly generated by the shape of the core.
These words are used here exactly as
[notation.md](../functional-design/references/notation.md#words-with-one-meaning)
defines them.
## When to use
- Structuring a new service, application, or module
- A test needs a database to check a business rule
- Deciding where a service call goes inside a workflow
- Domain code imports a framework, driver, or transport library
- A rule and an effect are tangled in one function
Not for: choosing how to supply a specific dependency, which is
[functional-parameterizing-dependencies](../functional-parameterizing-dependencies/SKILL.md).
## The layers
```text
+-------------------------------------------+
| shell: handlers, transport, storage |
| +-------------------------------------+ |
| | workflows: pipelines of pure steps | |
| | +-------------------------------+ | |
| | | domain: types and rules | | |
| | +-------------------------------+ | |
| +-------------------------------------+ |
+-------------------------------------------+
```
Dependencies point inward only. The domain knows nothing about workflows;
workflows know nothing about the shell. The two inner layers are the
core. The shell knows everything, and is the only layer allowed an
effect.
## Core rules
1. Rule. **The core is pure.** No function in the core has an effect
type, receives a capability, or calls one.
2. Default. **Read, decide, write.** A handler reads everything the core
needs, calls the core with those values, and writes what the core
returned. Where a read cannot come first, the core is two pure
functions and the handler reads between them. Rule 1 of
[functional-parameterizing-dependencies](../functional-parameterizing-dependencies/SKILL.md)
says when a read cannot come first.
3. Rule. **The core returns decisions; the shell acts on them.** A
decision is a value: a result, a new state, events, or instructions.
The core performs nothing.
4. Rule. **Nothing inward-facing imports outward.** No driver, framework,
or transport type appears in the core.
5. Rule. **Time, randomness and identifiers reach the core as values.**
The shell reads the clock once for each command or event it handles,
so every rule sees the same instant, and draws the identifiers the
core will use.
6. Rule. **The shell holds no business rule.** A handler branches on two
things only: which case a pure function returned, and how a
capability call came out. A condition on the contents of a value it
read is a rule, and belongs in a pure function. Retry counts,
timeouts and batch sizes are the adapter's, set by configuration;
what makes a write safe to try again is in
[functional-making-effects-reliable](../functional-making-effects-reliable/SKILL.md).
7. Judgement. **Keep handlers thin.** A handler that only reads, calls
and writes needs no test of its own. A transaction, a retry or an
ordering guarantee does: that work is real, and it goes into the
adapter, where it is tested against the real thing.
8. Default. **Wire in one place.** One function at the edge of each program, the
composition root, gives every handler what it reaches the world through: its
capabilities, or the connection its adapters take. Two things are bound
somewhere else. A test is a program of its own and wires its own. And a
capability built from something only a request has, such as a retry around an
adapter with that request's deadline, is bound at that request's entry point.
9. Rule. **Remove an effect where it is introduced, not where it
surfaced.** Calling something that has an effect gives the caller an
effect, so one lookup buried deep changes the type of every function
above it for no reason of its own. Pass that function the value it
was looking up. See
[what-belongs-where.md](references/what-belongs-where.md).
## Pattern
Tangled:
```text
processRefund : RefundRequest -> AsyncResult<Unit, RefundError>
processRefund req =
let booking = Db.load req.bookingId -- effect
let days = daysBetween booking.placed (Clock.now ()) -- effect
in if days > 60 then Error TooOld -- rule
else
let amount = min req.amount booking.total -- rule
let saved = Db.insertRefund booking.id amount -- effect
let sent = Email.send booking.patient "refunded" -- effect
in Ok ()
```
The two rules cannot be tested without a database, a clock, and a mail
server, and they are invisible among the plumbing.
Separated:
```text
-- core: pure, total, imports nothing
decideRefund :
Instant -> Booking -> RefundRequest
-> Result<RefundApproved, RefundError>
-- shell: a handler, which reads, calls the core, and writes
type RefundFailure =
| NotFound
| Rejected of RefundError
| Unavailable of StoreError
handleRefund :
Pool -> Instant -> RefundRequest
-> AsyncResult<RefundApproved, RefundFailure>
handleRefund pool now request =
loadBooking pool request.bookingId -- read
|> mapError Unavailable
|> bind (toResult NotFound)
|> bind (\booking ->
decideRefund now booking request -- decide
|> mapError Rejected)
|> bind (saveRefund pool >> mapError Unavailable) -- write
```
`decideRefund` is tested with three plain values and no setup. The
handler has no condition in it. Its failure type has three cases because
three different things can go wrong: nothing was found to decide about,
the rules refused, or storage could not be reached. Only the second is
the core's.
`RefundApproved` is an event, the fact that was decided, and
`saveRefund` writes it with the refund in one transaction. Telling the
patient is not this handler's work. The handler that receives
`RefundApproved` does that, and
[functional-designing-workflow-pipelines](../functional-designing-workflow-pipelines/SKILL.md)
says why.
## What goes where
| Belongs in the core | Belongs in the shell |
| ---------------------------- | -------------------------- |
| Domain types and invariants | HTTP, queues, sockets |
| Validation and parsing rules | Database access |
| Calculations and pricing | Clock, random, identifiers |
| State transitions | Retries and timeouts |
| Deciding which events happen | Delivering those events |
| Choosing what to store | Performing the write |
| Which failures have names | Converting driver errors |
| What must be audited | Logging and metrics |
More cases in
[what-belongs-where.md](references/what-belongs-where.md).
## Testing consequence
The split is what makes tests cheap, and the test suite's shape is a
reliable diagnostic:
- Core tests: plain values in, assertions on values out. No framework, no
setup, fast enough to run thousands.
- Handler tests, for a handler that reads between two pure functions: a
stub for each capability, and one test for each path through its
reads. A handler that reads once and writes once has no path to test,
and the integration tests cover it.
- Shell tests: a small number of integration tests proving the wiring,
the mapping, and the transactions work.
If a business rule needs a database to test, the split has failed
somewhere, and the test difficulty is telling you where. See
[functional-testing-functional-code](../functional-testing-functional-code/SKILL.md).
## Red flags
- A domain module importing a driver, client, or framework
- A core function with an effect type, or a capability among its
parameters
- A rule expressed inside a database query
- `now()` or `uuid()` called anywhere inside the core
- A workflow that sends an email itself
- Business logic in a controller, a resolver, or a job runner
- Tests for pricing that start a container
- A shell function containing a conditional about business meaning
## Common mistakes
- **Calling it done because there is a folder called domain.** The test
is whether the code is pure, not where the file lives.
- **Pushing I/O out but keeping rules in the shell.** A shell that
decides is the same problem with more files.
- **Calling effectful code core.** A function with an effect type is
shell, whatever folder it lives in. If most of the "core" has one, an
effect was introduced somewhere below, and rule 9 says what to do
about it.
- **Passing a capability into the core so that a stub can replace it.**
The stub works, and the rule inside now needs one to run. Read first
and pass the value.
- **A shell so thin it is a lie.** Transactions, retries, and ordering
are real work; they belong in the shell and deserve care there.
- **Over-abstracting the boundary.** A function type per capability is
enough. A plugin system is not.
## Related skills
- [functional-parameterizing-dependencies](../functional-parameterizing-dependencies/SKILL.md)
- [functional-designing-workflow-pipelines](../functional-designing-workflow-pipelines/SKILL.md)
- [functional-crossing-io-boundaries](../functional-crossing-io-boundaries/SKILL.md)
- [functional-managing-state-immutably](../functional-managing-state-immutably/SKILL.md)
## Further reading
- [architecture-shapes.md](references/architecture-shapes.md) relates the
split to onion, hexagonal, and clean architectures, and shows the
shell's real structure.
- [what-belongs-where.md](references/what-belongs-where.md) explains how
the split comes undone, and is a decision procedure for the ambiguous
cases.