Chapter 17 · Case study: HTTP server
This is the first of two case studies that close the book. In it you see 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 concurrency, modularity, and the separation between
domain logic and IO carry the weight. Chapter 18 covers the
other end of the industry: a ledger, where precise types
(currencies with units), business invariants
(requires/ensures contracts), and strict immutability carry
it.
The program is a notes server. It has 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 logic is simple; the interesting part is
how it’s wired: effects in the signatures, actors to
encapsulate state, fibers for concurrent connections, modules
to separate domain, HTTP parser, storage, persistence.
It runs to about 430 lines, across five files.
17.1 The shape of the program
Before reading the code, place 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
Each file carries one concern:
domain.kaiis the center. Pure types, no effects, no IO: what a note is, what commands can be issued, what responses can be produced.store.kaiis an actor. It receives commands, holds the list of notes as internal state, and replies to whoever asked. The logic inside is pure (functionprocess), wrapped in anActor.receive()loop that connects it to the world.persistence.kaiis another actor. It receives events (creation, deletion) and writes them to a log file. With the disk isolated in an actor, the store keeps 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. You test the
domain logic 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. There are four commands against the domain (list, get one, create, delete) and five possible responses, which map conceptually to HTTP 200, 201, 404, 400, and 500.
What’s not here: HTTP, fibers, files. If one day you expose the API over gRPC instead of HTTP, or switch storage from memory to SQLite, this file doesn’t change. It’s the program’s invariant.
The #[derive(Show)] on Note lets you interpolate #{note}
in a string (chapter 9). Without it, you’d 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 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 opens its own mailbox with
with_mailbox, puts the Pid in the message, and waits for
the reply: 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: nothing new, just the constructs from chapters
5 (sum types and match) and 6 (functions and pipelines) put to
work.
Because process is pure, you test it directly:
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
}
No fibers, no IO, no sockets: just logic. If tomorrow you want to parallelize note creation, add indexes, or change the search algorithm, the change goes through this pure function and gets tested here.
On top of process sits the actor loop that connects it
to Actor.receive():
pub fn store_loop(notes: [domain.Note], next_id: Int) : Unit / Actor[StoreMsg] + Actor[StoreResp] {
match Actor.receive() {
Ask(command, client) -> {
let (reply, new_notes, new_id) = process(command, notes, next_id)
Actor.send(client, Replied(reply))
store_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. The signature declares the actor’s two
effects: Actor[StoreMsg] to receive,
Actor[StoreResp] to reply.
The module ships no helper to start the actor; main does
that with spawn_actor (§17.6). What it does export is 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
}
}
The HTTP handlers in main call ask: “send this question
to the store and give me the answer”. Underneath it’s a send
followed by a receive, exposed as a simple function rather
than 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 fs.file
pub type Event = Line(String)
pub fn persistence_loop(path: String) : Unit / Actor[Event] + File {
match Actor.receive() {
Line(s) -> {
file.append(path, s ++ "\n")
persistence_loop(path)
}
}
}
pub fn reset_log(path: String) : Unit / File {
file.write(path, "")
}
Isolating the file write in its own actor has two benefits:
- The store doesn’t wait on disk. Processing 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 once.
In a real system, this actor would have a Bounded(N, DropOldest) mailbox to protect against flooding. Here I use
the default (Unbounded) to keep the demo simple. 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 signature stays compact:
either the request translates to a valid command, or you
already have the response.
The module also has a parser for the first HTTP line (GET /path HTTP/1.1) and a serializer that produces the response
bytes. Both are pure functions you test with input strings
and output comparison.
17.6 main: assemble the pieces
main.kai is the glue:
import actor
import spawn
import loop
import net.tcp
import domain
import store
import persistence
import web
const PORT: Int = 8080
const LOG_PATH: String = "notes.log"
fn main() : Int / Stdout + NetTcp + File + Spawn + Cancel {
with_mailbox {
with_mailbox {
persistence.reset_log(LOG_PATH)
nursery { n ->
let store_body: () -> Unit / Actor[store.StoreMsg] + Actor[store.StoreResp] = () => store.store_loop([], 1)
let log_body: () -> Unit / Actor[persistence.Event] + File = () => persistence.persistence_loop(LOG_PATH)
let store_pid = spawn_actor(store_body)
let log_pid = spawn_actor(log_body)
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}")
forever(() => match NetTcp.accept(listener) {
Err(_) -> ()
Ok(conn) -> {
let _ = n.spawn(() => with_mailbox(() => serve_connection(conn, store_pid, log_pid)))
()
}
})
}
}
}
}
}
0
}
Read from the outside in:
- The two nested
with_mailboxblocks givemainthe mailboxes it needs to talk to the store and the persister. They are also why the signature lists noActor[X]: a handled effect leaves the row. reset_logstarts the audit file empty.- The
nurseryopens the scope the child fibers will live in. spawn_actorstarts the two actors, each with its body declared at an explicit type.NetTcp.listenopens the socket andforeverenters the accept loop.
main’s effect row lists what the program still has live on
the way out: Stdout to print, NetTcp for sockets, File
to write, Spawn + Cancel for fibers. If main did more,
its row would grow.
The accept loop 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(() => with_mailbox(() => serve_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.
Graceful shutdown: trapping the signal
That “or the program receives SIGINT” is what separates a toy server from one you can leave running. By default a Ctrl-C kills the process wherever it stands: mid-write on a file, mid-response on a connection.
The Signal effect turns that interruption into something
your program decides when to handle:
# examples/ch17/01_signal.kai
import effects.os
fn main() : Unit / Stdout + Signal = {
Signal.on(SigInt)
println("waiting for Ctrl-C...")
let _s = Signal.await()
println("signal received, shutting down cleanly")
}
Run it and send a Ctrl-C: it waits until the signal arrives, so there is no output to show before that.
Signal.on(SigInt) subscribes; Signal.await() suspends the
fiber until one arrives. That makes it a yield point like any
other: the rest of the program keeps running while this fiber
waits.
In a server, a dedicated fiber waits on the signal and cancels
the listener when it fires. The
cancellation descends through the nursery, each connection
fiber runs its Cancel handler at its next yield point. The
log doesn’t depend on that shutdown: the persistence actor
appends each event to the file as soon as it arrives. It is
chapter 13’s mechanism, with the operating system’s signal as
the trigger.
And per connection, the handler:
fn serve_connection(conn: Conn, store_pid: Pid[store.StoreMsg], log_pid: Pid[persistence.Event]) : Unit / NetTcp + Actor[store.StoreMsg] + Actor[store.StoreResp] + Actor[persistence.Event] + Cancel {
let raw = read_request(conn)
let wire = handle_request(raw, store_pid, log_pid)
let _ = NetTcp.send(conn, web.string_to_bytes(wire))
NetTcp.close(conn)
}
Four lines, because the work lives in handle_request: parse
HTTP, route to a command, log it, ask the store, serialize the
response. What stays here are the two ends that touch the
socket — read and write — plus the 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
These are the pieces of the book you put to use, 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.
No new syntax here. What’s new is the combination: small, orthogonal pieces fitting into a program with real responsibilities.
17.8 How to extend it
You can take this program further in several directions:
- 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; theClockeffect appears instore_loop’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, and none changes the basic structure: a pure domain, actors with state, fibers for concurrency, modules for separation.
17.9 What this case shows
What you just put together follows a clear pattern:
- 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. One fiber per connection, spread by the scheduler across OS threads. 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 four.
In chapter 18 you apply the same pattern to a very different domain, financial accounting, and the structure holds even though the problem changes.