Mocks, renderers and kernels are all effect handlers

Take a signup: find the user with this email, create them if they are new, and send them a welcome email. With dependency injection, the signup does not build its own database and mail clients; it is handed them:

async function signUp(email, db, mail) {
const user = await db.findUser(email)
if (user) return "exists"
const id = await db.insertUser(email)
await mail.sendEmail(email)
return id
}

Bernhardt's functional core, imperative shell gives the same advice from the other side: keep the logic pure and do the I/O in a thin layer around it. Both move the code that touches the world out of the program. Is that one idea, and does it have a name?

It is one idea, and the theory of algebraic effects and handlers names its parts [1]. An effect is an interface of requests a program can make without implementing them: here, the methods of db and mail. Each request, such as db.findUser(email), is an operation. A handler, installed around the program from outside, gives the operations their meaning.1

In dependency injection the handler is the object passed in. A test passes mocks, and the same signup runs with no database or mail server at all:

const db = { findUser: async () => null, insertUser: async () => 7 }
const mail = { sendEmail: async () => {} }
await signUp("ana@example.com", db, mail) // 7

In functional core, imperative shell the handler is the shell. In Bernhardt's original form the shell answers everything at the edges: it gathers the inputs, calls the pure core with plain values, and acts on the values the core returns, so the core never has to ask for anything. A daily job that nudges users who signed up but never logged in shows the split:

// core: decides who gets a nudge, from plain values
function nudges(users, now) {
const threeDays = 3 * 24 * 60 * 60 * 1000
return users
.filter((u) => !u.lastLoginAt && now - u.signedUpAt > threeDays)
.map((u) => ({ to: u.email, template: "come-back" }))
}
// shell: gathers the inputs, calls the core, sends what it returns
async function sendNudges(db, mail) {
const users = await db.usersSignedUpThisWeek()
for (const nudge of nudges(users, Date.now())) await mail.send(nudge)
}

Even the clock is an input the shell answers: a test calls nudges with a few users and a fixed now and checks the list, with no database, mail server or waiting three days.

React and Elm build whole frameworks on a core that returns its requests as data. A component never touches the page; it returns a description of what the page should show:

function Welcome({ user }) {
return <p>Welcome, {user.name}</p>
}

The renderer is the handler that carries the description out, and it can be swapped with the component unchanged: react-dom turns it into elements on a browser page, and renderToString turns it into HTML on a server. React Native and Ink go further and drive phone views or a terminal with the same React core, though components there use those targets' own elements. Elm does the same for I/O: update returns commands such as an HTTP request as data, and the runtime performs them and feeds the answer back as a message.

The operating system is the last handler, and every program already runs under it. A system call is an operation: the program asks for read(fd) and traps, handing control to the kernel, which performs the read and hands back the bytes. Take a file notes.txt holding one line, buy milk. Here is cat notes.txt under strace, which prints each system call with its answer (trimmed to the calls on the file; startup calls and cat's own output left out):

openat(AT_FDCWD, "notes.txt", O_RDONLY) = 3
read(3, "buy milk\n", 131072) = 9
write(1, "buy milk\n", 9) = 9
read(3, "", 131072) = 0

Each line is one operation, with the kernel's answer after the =. cat opens the file (the answer, 3, is the kernel's number for it), reads it (9 bytes land in cat's buffer), writes them to file 1, the terminal, and reads again until the answer is 0 bytes, the end of the file. It never touches the disk or the screen itself; every step is a request the kernel answers.

This handler can be swapped too, with the program unchanged, and the common tools take over more and more of the answering:

  • Watch every answer. strace, as above, sits between the program and the kernel and prints each call as it passes. eBPF tracing tools such as bpftrace watch the same calls from inside the kernel, at far lower cost.
  • Answer some requests itself. By default, every Docker container runs under a seccomp filter, a small BPF program the kernel runs before handling each system call. For about 44 calls, such as reboot, Docker's default filter answers "operation not permitted" at once, and the kernel never performs them. The program asked and got an answer; it cannot tell who answered.
  • Answer every request. gVisor sends all of a container's system calls to its own kernel, written in Go.

In all three patterns, the program never changes while the handler does: the mock db, the swapped renderer and the sandboxed kernel each run the same code against a different world. A production handler, a test handler or a sandbox decides what each operation means, and that is what the three patterns are for.

The kernel holds one thing the other handlers never see: the program itself, paused at the trap, with everything it will do once the read returns. The theory calls that paused rest of the program the continuation [1]. For a process it is concrete: the saved CPU registers, including where to resume, and the process's memory. The kernel resumes it by writing the answer into a register, the = 9 above, and jumping back. The injected db resumes the program only by resolving its promise, once. The kernel holds the continuation itself: it can resume it later, never, or twice, which is what fork does.

Footnotes

  1. Matija Pretnar's tutorial works through the calculus, and Dan Abramov's essay gives the idea in JavaScript. ↩

References

  1. Handlers of Algebraic Effects
    Plotkin, G. and Pretnar, M., 2009. Programming Languages and Systems (ESOP 2009), Lecture Notes in Computer Science. DOI: 10.1007/978-3-642-00590-9_7