Chapter 17 · Case study: HTTP server
We’ve reached the first of two case studies that close the book. The point is to see, in one place, how the pieces from the previous chapters fit into something resembling real software. I distrust language books that never leave the toy example, so I wanted to close this one with two programs that could plausibly ship.
This chapter covers an HTTP server: the family of
problems where what matters is concurrency, modularity, and
the separation between domain logic and IO. Chapter 18
will cover the other end of the industry spectrum: a
ledger, where what matters is precise types (currencies
with units), business invariants (requires/ensures
contracts), and strict immutability. Two cases of the same
language, with very different things to worry about in each.
The program: a notes server with a simple HTTP interface
(GET /notes, POST /notes, GET /notes/<id>, DELETE /notes/<id>), keeps notes in memory, and writes each
change to a log file. The “real” part isn’t the logic
(it’s simple), it’s how it’s wired: effects in the
signatures, actors to encapsulate state, fibers for
concurrent connections, modules to separate domain, HTTP
parser, storage, persistence.
Program size: about 250 lines, across five files.
17.1 The shape of the program
Before the code, the pieces and their responsibilities:
notes/
├── kai.toml # project manifest
├── main.kai # entry point and accept loop
├── domain.kai # types: Note, Command, Reply
├── store.kai # actor that holds the notes
├── persistence.kai # actor that writes the log
└── web.kai # minimal HTTP parser and serializer
The five files split the work into five concerns:
domain.kaiis the center. Pure types, no effects, no IO. What the domain “is”: what a note is, what commands can be issued, what responses can be produced.store.kaiis an actor. Receives commands, holds the list of notes as internal state, replies to whoever asked. The manipulation logic inside is pure (functionprocess), wrapped in anActor.receive()loop that connects it to the world.persistence.kaiis another actor. Receives events (creation, deletion), writes them to a log file. Isolating the disk in an actor lets the store keep responding even if the write is slow.web.kaiis pure functions: parsing HTTP bytes into aHttpReqstructure, translating requests into domain commands, serializing responses to bytes. No actors, no IO.main.kaiwires it all: starts the actors, opens the TCP socket, opens a nursery, and spawns a fiber for each new connection.
This separation is the natural shape in kaikai. Each module
is orthogonal: the pure domain logic can be tested without
starting fibers, the HTTP parser without opening sockets,
the store without touching disk. main just connects the
pieces.

Figure 17.1 · Notes server architecture. Five modules,
two actors (store and persistence), one fiber per
incoming connection. The pure modules (domain.kai,
web.kai, green cluster) have no effects; the stateful
modules (store.kai, persistence.kai, red cluster)
hide their mutation behind a mailbox; main.kai is glue.
17.2 The domain: pure types
We start in the center. domain.kai:
#[derive(Show)]
pub type Note = { id: Int, body: String }
pub type Command
= List
| Get(Int)
| Create(String)
| Delete(Int)
pub type Reply
= Stored(String)
| Created(Note)
| NotFound
| ClientError(String)
| ServerError(String)
Three declarations. A note has an id and a body. Four commands can be issued against the domain (list, get one, create, delete). Five possible responses, which map conceptually to HTTP 200, 201, 404, 400, and 500.
What’s not here: no HTTP, no fibers, no files. If one day you decide to expose the API over gRPC instead of HTTP, this file doesn’t change. If you decide to switch storage from memory to SQLite, this file doesn’t change. It’s the program’s invariant.
The #[derive(Show)] on Note is what lets us interpolate
#{note} in a string (chapter 9). Without it, we’d have
to write impl Show for Note by hand.
17.3 The store: actor with state
store.kai defines an actor that keeps the list of notes
and answers commands. Its message type is the command plus
the Pid to reply to:
import actor
import domain
pub type StoreMsg = Ask(domain.Command, Pid[StoreResp])
pub type StoreResp = Replied(domain.Reply)
StoreMsg is what the store receives; StoreResp is what
it returns. The client, before sending, opens its own
mailbox with with_mailbox, puts the Pid in the message,
and then waits for the reply. It’s the request/reply
pattern from §14.5.
The heart of the module is the pure logic of processing:
pub fn process(c: domain.Command, notes: [domain.Note], next_id: Int)
: (domain.Reply, [domain.Note], Int) {
match c {
List -> {
let bodies = list.map(notes, .body)
(domain.Stored(serialize_list(bodies)), notes, next_id)
}
Get(id) ->
match find(notes, id) {
Some(n) -> (domain.Stored(n.body), notes, next_id)
None -> (domain.NotFound, notes, next_id)
}
Create(body) -> {
let n = domain.Note { id: next_id, body: body }
(domain.Created(n), [n, ...notes], next_id + 1)
}
Delete(id) ->
match find(notes, id) {
Some(_) -> {
let remaining = list.filter(notes, (n) => n.id != id)
(domain.Stored("deleted"), remaining, next_id)
}
None -> (domain.NotFound, notes, next_id)
}
}
}
A single function, no effects in its signature. Takes the
command, the current notes, and the next id; returns the
response, the new note list, and the new next id.
Exhaustive pattern match over the four variants of
Command. Lists built with [h, ...tail]. list.map and
list.filter. None of this is new in chapter 17: it’s the
chapter-5 constructs (sum types and match) and chapter-6
(functions and pipelines) put to work.
Because process is pure, it’s directly testable:
test "create and get" {
let (r1, n1, id1) = process(domain.Create("first"), [], 1)
let created_ok = match r1 {
domain.Created(_) -> true
_ -> false
}
assert created_ok
assert id1 == 2
let (r2, _, _) = process(domain.Get(1), n1, id1)
let get_ok = match r2 {
domain.Stored(c) -> c == "first"
_ -> false
}
assert get_ok
}
There are no fibers, no IO, no sockets in sight: it’s just logic. If tomorrow we want to parallelize note creation, add indexes, or change the search algorithm, all those changes go through this pure function and get tested here.
On top of process sits the actor loop that connects it
to Actor.receive():
fn loop(notes: [domain.Note], next_id: Int)
: Unit / Actor[StoreMsg] + Actor[StoreResp] {
match Actor.receive() {
Ask(command, client) -> {
let (resp, new_notes, new_id) = process(command, notes, next_id)
Actor.send(client, Replied(resp))
loop(new_notes, new_id)
}
}
}
Three lines of work:
- Receives an ask.
- Processes it (pure logic).
- Replies and recurses with the new state.
Tail recursion compiles to a loop (chapter 6), so the actor
can run indefinitely. And the signature declares the two
effects the actor produces: Actor[StoreMsg] to receive,
Actor[StoreResp] to reply.
The start helper wires it together:
pub fn start() : Pid[StoreMsg] / Spawn + Cancel + Actor[StoreMsg] + Actor[StoreResp] {
spawn_actor(() => loop([], 1))
}
And a synchronous wrapper for clients:
pub fn ask(store: Pid[StoreMsg], c: domain.Command)
: domain.Reply / Actor[StoreMsg] + Actor[StoreResp] + Cancel {
Actor.send(store, Ask(c, Actor.self()))
match Actor.receive() {
Replied(r) -> r
}
}
ask is what the HTTP handlers in main call: “send this
question to the store and give me the answer”. Underneath
it’s a send followed by a receive. We expose it as a simple
function, not an open protocol.
17.4 Persistence: writing actor
persistence.kai is simpler. An actor that receives log
lines and appends them to a file:
import actor
import fs.file
pub type Event = Line(String)
fn loop(path: String) : Unit / Actor[Event] + File {
match Actor.receive() {
Line(s) -> {
file.append(path, s ++ "\n")
loop(path)
}
}
}
pub fn start(path: String) : Pid[Event] / Spawn + Cancel + Actor[Event] + File {
file.write(path, "") # truncate at start
spawn_actor(() => loop(path))
}
Isolating the file write in its own actor has two benefits:
- The store doesn’t wait on disk. When the store
processes a
Create, it sends a message to the persistence actor and goes back to its work. The write happens in another fiber. - Write order is guaranteed. All events go through the same mailbox, which is processed in FIFO order. No races even if several handlers write to the log at the same time.
In a real system, this actor would have a Bounded(N, DropOldest) mailbox to protect against flooding. Here we
use the default (Unbounded) for simplicity. The decision is
explicit and lives in one line, easy to change.
17.5 HTTP parser
web.kai is pure string manipulation. The central piece
is route, which translates an HTTP request into a domain
command:
pub fn route(req: HttpReq) : Result[domain.Command, domain.Reply] {
if req.method == "GET" {
if req.path == "/notes" {
Ok(domain.List)
} else {
route_id(req.path, (id) => domain.Get(id))
}
} else if req.method == "POST" {
if req.path == "/notes" {
Ok(domain.Create(req.body))
} else {
Err(domain.NotFound)
}
} else if req.method == "DELETE" {
route_id(req.path, (id) => domain.Delete(id))
} else {
Err(domain.NotFound)
}
}
The return type uses Result in a slightly unorthodox way:
Ok carries a command to execute; Err carries an
immediate response (404, 400). The convention keeps the
signature compact: either the request translates to a valid
command, or we have the response directly.
There’s also a parser for the first HTTP line (GET /path HTTP/1.1) and a serializer that produces the response
bytes. Pure functions with no effects, testable with input
strings and output comparison.
17.6 main: assemble the pieces
main.kai is the glue:
import actor
import spawn
import fs.file
import domain
import store
import persistence
import net.tcp
import web
const PORT : Int = 8080
const LOG_PATH : String = "notes.log"
fn main() : Unit / Console + NetTcp + File + Spawn + Cancel + Actor[store.StoreMsg] + Actor[store.StoreResp] + Actor[persistence.Event] {
let store_pid = store.start()
let log_pid = persistence.start(LOG_PATH)
match NetTcp.listen("0.0.0.0", PORT) {
Err(msg) -> println("failed to start the server: " ++ msg)
Ok(listener) -> {
println("server listening on port #{PORT}")
accept_loop(listener, store_pid, log_pid)
}
}
}
Four lines of “business”:
- Start the store (actor that holds the notes).
- Start the persister (actor that writes the log).
- Open a TCP socket on the port.
- Enter the accept loop.
main’s effect row lists everything the program uses:
Console to print, NetTcp for sockets, File to write,
Spawn + Cancel for fibers, Actor[X] for each of the
three message channels. The signature hides nothing: if
main did more things, its row would grow accordingly.
The accept loop opens a nursery and spawns a fiber per
connection. Note: n is not a Nursery value that can
travel to another function — the compiler rewrites every
n.spawn(...) to Spawn.spawn(...) tagged with this
nursery’s brand, so the spawn has to appear lexically
inside the block. That’s why the accept loop is inline:
nursery { n ->
forever(() => match NetTcp.accept(listener) {
Err(_) -> ()
Ok(conn) -> {
let _ = n.spawn(() => handle_connection(conn, store_pid, log_pid))
()
}
})
}
Each connection lives in its own fiber. The nursery guarantees that when the loop ends (because someone cancels the listener, or the program receives SIGINT), the child fibers end too. No zombie connection handlers.
And per connection, the handler:
fn handle_connection(conn, store_pid, log_pid) {
let raw = read_request(conn)
let resp = match web.parse_request(raw) {
Err(msg) -> domain.ClientError(msg)
Ok(req) -> match web.route(req) {
Err(r) -> r
Ok(command) -> {
record(log_pid, command)
store.ask(store_pid, command)
}
}
}
NetTcp.send(conn, string_to_bytes(web.serialize_response(resp)))
NetTcp.close(conn)
}
Read bytes, parse HTTP, route to a command, log it, ask the store, serialize the response, write to the socket, close. Each step is either a pure function or a message to an actor, so there’s no shared memory and nothing to lock.
17.7 How this maps to the book
It’s worth pointing out which pieces of the book are in play, one by one:
- Ch. 2 (thinking in kaikai): functions are
expressions;
processreturns a tuple in one expression. - Ch. 4 (compound types): return tuples (
(Reply, [Note], Int)), records (Note), lists with head/tail patterns. - Ch. 5 (sum types and match):
Command,Reply,Eventare sum types; matches cover all variants; exhaustiveness is verified by the compiler. - Ch. 6 (functions and pipelines):
list.map,list.filterover the list of notes; closures passed to those functions. - Ch. 7 (tests): tests over
processthat verify the logic without starting fibers. - Ch. 8 (modules): five files each with its
pub, imports between them. - Ch. 9 (protocols):
#[derive(Show)]for interpolating notes. - Ch. 12 (effects): each function declares its row;
handledoesn’t appear directly because thehandles live insidewith_mailboxandspawn_actorfrom the stdlib. - Ch. 13 (fibers):
nurseryto structure the accept loop; each connection is a fiber. - Ch. 14 (actors): the store and persistence are
actors;
with_mailboxin each client;spawn_actorto start them; typed messages. - Ch. 16 (tooling):
kai run main.kaistarts it;kai testruns the tests in thestoremodule.
Nothing here is new in terms of syntax. What’s new is the combination: small, orthogonal pieces fitting into a program with real responsibilities.
17.8 How to extend it
Several directions to take this program further:
- Persistence with recovery. Today the log is write-only. If the server restarts, notes are lost. A natural extension: at startup, read the log and replay events to rebuild state.
- Search by content. The
Getcommand searches by id. Add aSearch(String)that filters by body substring. The pure logic goes inprocess; theroutematchgains a branch. - TTL per note. Each note has an expiration. The
store, on each
Get, checks whether the note expired and removes it if so. Acreated_atfield joinsNote; theTimeeffect appears inloop’s row. - Multiple instances. Today there’s one store. For a
bigger service, partition notes across several actors by
id hash.
mainstarts N stores and routes each request to the right one. - Metrics. A fourth actor that receives events
(
request_received,note_created,error_emitted) and accumulates counters.mainstarts it, the handlers send it events, an endpointGET /metricsreads. - Integration test. A client program that opens a TCP connection to the server, sends a request, reads the response, verifies it’s what’s expected. Puts the server in a nursery, runs the client, closes.
Each is an afternoon’s work. None requires changing the basic structure: a pure domain, actors with state, fibers for concurrency, modules for separation.
17.9 What this case shows
There’s a clear pattern in what we’ve just put together:
- The domain is pure.
Command,Reply,process: types and functions with no effects. They’re tested with inputs and outputs, without starting anything. - Actors encapsulate the mutable state. The store holds the note list; the persister holds the log file. Mutation stays locked inside each actor, invisible to the rest of the program.
- Fibers handle concurrent work cooperatively. One fiber per connection. The nursery guarantees that none survives the server.
- Modules separate responsibilities. Five files, each owning one topic, so each can be swapped without touching the other three.
Chapter 18 will apply exactly the same pattern to a very different domain (financial accounting) and you’ll see how the structure holds even when the problem changes. The book’s closing comes there.