Composable Architecture
Designing systems as a pure core surrounded by thin adapters — and the architectural style functional thinking naturally produces.
The chapters so far have given you a toolbox. Now we'll talk about the shape of a system that emerges when you use that toolbox consistently. It's sometimes called functional core, imperative shell, sometimes onion / hexagonal architecture. The names differ; the idea is the same.
The picture
The core is a pile of pure functions and immutable types: it takes in data, produces new data. It contains all the business rules and none of the I/O. The shell is a thin layer that fetches data from the world, hands it to the core, and writes the core's output back out.
Two rules carry the whole architecture:
- Effects live at the edge. Database calls, HTTP requests, filesystem access, time, randomness — everything that touches the outside world — happens in the shell. The core is given data; it doesn't go fetch it.
- Decisions live in the core. No important business decision is made in the shell. The shell only knows "call this pure function with this data; do what it says".
Why this design wins
It's not aesthetics. There are concrete benefits.
Testing. The core is testable without mocks. You feed it plain data; you check the data it returns. No need to stub a database, no test containers, no flaky network. Most of your business logic gets tested in microseconds.
Replaceability. The shell is boring and replaceable. You can swap PostgreSQL for SQLite, REST for GraphQL, Express for Fastify — without touching the core. The core only knows about your domain, not your infrastructure.
Reasoning. When a bug appears in business logic, you don't have to think about "did the network glitch?" — the logic is pure. The bug is in the function, end of story.
Evolution. New requirements usually live in the core. You add or modify functions; the shell barely changes.
A worked example
Suppose we want a small "billing summary" endpoint. The shell gets the data; the core decides the output.
Notice how easy it is to test summarize: it's a pure function
of (today, invoices) → Summary. No mocks, no setup, no
teardown. The shell's handler is so thin that almost any bug it
could contain is "I passed the wrong thing in" — easy to spot.
The "ports and adapters" view
Another name for the same pattern: the core defines ports
(plain function or interface types) that say "I need a way to
fetch invoices, given an id, that returns Promise<Invoice[]>".
The shell provides adapters that satisfy those ports against
real systems (a Postgres adapter, an in-memory adapter for tests,
a REST adapter, etc.).
// PORT — defined by the core, language: domain
type InvoiceRepo = {
list: (customerId: string) => Promise<ReadonlyArray<Invoice>>;
};
// ADAPTER — lives in the shell, language: infrastructure
const sqlInvoiceRepo: InvoiceRepo = {
list: async (customerId) => /* SELECT ... */ [],
};
// The core function takes the port as a parameter — it doesn't
// care which adapter is wired in
async function customerSummary(repo: InvoiceRepo, customerId: string, today: string) {
const invoices = await repo.list(customerId);
return summarize(today, invoices);
}You can swap sqlInvoiceRepo for an in-memory test repo with one
line — no code in the core knows or cares.
Composing pipelines as architecture
In the small, composability shows up as
pipe(x, parse, validate, transform, render). In the large, the
same idea applies: an application is a pipeline of
small, composable, well-typed transformations from input to
output, with thin adapters at the ends.
When you internalize that, all the FP techniques you learned become architectural, not just micro-stylistic:
Result<E, A>carries domain errors through the pipeline.Task<A>/TaskResult<E, A>carry async work without scattering try/catch.- Reducers carry state changes without globals.
- Pure transitions become testable in microseconds.
- Adapters isolate the parts you'll want to change.
That is composable architecture. It scales from a 50-line script to a hundred-thousand-line system without changing shape.
A small architecture exercise
The starter has a function reportTopSpender
that does everything — it reads orders from a hardcoded array,
computes a top spender, formats a string, and console.logs it.
Refactor it: in core.ts, write a pure function
topSpender(orders) that returns the top customer id (or
null if the list is empty). In main.ts, the shell calls
topSpender with the data and logs the result.
Expected output (unchanged):
top spender: u2
In a "functional core, imperative shell" architecture, where do business decisions live?
In the database, expressed as constraints and triggers
In the shell, because the shell knows where the data came from
In the pure core, as functions of (state, input) → output that depend on no I/O
Spread evenly throughout the codebase