4.4 KiB
Project Philosophy
Principles
-
Explicit over clever — no magic helpers, no macros that hide control flow, no trait gymnastics. Code reads top-to-bottom. A new reader should understand what a function does without chasing through layers of indirection.
-
Pure functions — isolate decision logic from IO. A function that takes data and returns data is testable, composable, and easy to reason about. Keep it that way. Don't sneak in network calls or logging.
-
Linear flow — avoid callbacks, deep nesting, and async gymnastics where possible. A handler should read like a sequence of steps: look up the record, pick a volume, build the response.
-
Minimize shared state — pass values explicitly. Don't hold locks across IO. Don't reach into globals.
-
Minimize indirection — don't hide logic behind abstractions that exist "in case we need to swap the implementation later." We won't. A three-line function inline is better than a trait with one implementor.
Applying the principles: separate decisions from execution
Every request handler does two things: decides what should happen, then executes IO to make it happen. These should be separate functions.
A decision is a pure function. It takes data in, returns a description of what
to do. It doesn't call the network, doesn't touch the database, doesn't log.
It can be tested with assert_eq! and nothing else.
Execution is the messy part — HTTP calls, SQLite writes, error recovery. It reads the decision and carries it out. It's tested with integration tests.
Where this applies today
Already pure
hasher.rs — the entire module is pure. volumes_for_key is a
deterministic function of its inputs. No IO, no state mutation. This is the
gold standard for the project.
rebalance.rs::plan_rebalance — takes a slice of records and returns a
list of moves. Pure decision logic, tested with unit tests.
db.rs encode/parse — parse_volumes and encode_volumes are pure
transformations between JSON strings and Vec<String>.
Mixed (decision + execution interleaved)
server.rs::put_key — this handler does three things in one function:
- Decide which volumes to write to (pure —
volumes_for_key) - Execute fan-out PUTs to nginx (IO)
- Decide whether to rollback based on results (pure — check which succeeded)
- Execute rollback DELETEs and/or index write (IO)
Steps 1 and 3 could be extracted as pure functions if they grow more complex.
Intentionally impure
rebuild.rs — walks nginx autoindex and bulk-inserts into SQLite. The IO
is the whole point; there's no decision logic worth extracting.
db.rs — wraps SQLite behind Arc<Mutex<Connection>> with
spawn_blocking to avoid blocking the tokio runtime. The mutex serializes all
access; SQLITE_OPEN_NO_MUTEX disables SQLite's internal locking since the
application mutex handles it.
Guidelines
-
If a function takes only data and returns only data, it's pure. Keep it that way. Don't sneak in logging, metrics, or "just one network call."
-
If a handler has an
iformatchthat decides between outcomes, that decision can probably be a pure function. Extract it. Name it. Test it. -
IO boundaries should be thin. Format URL, make request, check status, return bytes. No business logic.
-
Don't over-abstract. A three-line pure function inline in a handler is fine. Extract it when it gets complex enough to need its own tests, or when the same decision appears in multiple places (e.g., rebuild and rebalance both use
volumes_for_key). -
Errors are data.
AppErroris a value, not an exception. Functions returnResult, handlers pattern-match on it. TheIntoResponseimpl is the only place where errors become HTTP responses — one place, one mapping.
Anti-patterns to avoid
-
God handler — a 100-line async fn that reads the DB, calls volumes, makes decisions, handles errors, and formats the response. Break it up.
-
Hidden state reads — if a function needs data, pass it in. Don't reach into a global or lock a mutex inside a "pure" function.
-
Testing IO to test logic — if you need a Docker container running to test whether volume selection works correctly, the logic isn't separated from the IO.