Low Level Design
The server hexagon
Seventeen ports, their adapters, and a purity rule that a grep enforces. Why the ports are generic rather than dynamic, and why exactly one of them breaks that rule.
Suggest an editThe server hexagon
You'll be able to: read a bounded context's four layers and predict what each may import; explain why a port is generic instead of
dynand name the cost of that choice; and spot the one place in this codebase where a cross-context dependency is legitimate.
Four layers, sized to the work
Every bounded context is laid out the same way:
server/src/<context>/
domain/ pure types and rules — no framework, ever
application/ use cases; declares output PORTS; owns the error enum
infrastructure/ adapters that implement the ports
http/ inbound adapter: routes → use cases; DTO ↔ domain mappingThe layering is proportional to the work, not applied uniformly. submission earns all four —
it has an aggregate, a state machine, five ports and a background task. platform stays flat: it is
health checks, security headers and three proxies, and giving it a domain/ would be ceremony. So do
progress and insights, which are three files each: a port, a Postgres adapter and a router. The
rule is that structure is justified by complexity, not by symmetry.
The clearest evidence that the rule is real rather than stated is the spread. Measured in lines of
Rust, authoring and catalog are each an order of magnitude larger than progress or insights —
and they are all "contexts". A codebase where every context is the same size is one where the
layering was applied by template.
The ports
Seventeen traits, each the narrowest capability its use case needs. This is Interface Segregation
applied literally — TokenVerifier and KeycloakAdmin are separate ports because verifying a token
and deleting a user are different jobs with different failure modes, even though both are "Keycloak".
| Port | Context | Adapter | Backed by |
|---|---|---|---|
ContentRepository |
catalog | FileSystemContentRepository |
disk (git-sync checkout) |
CodeRunner |
execution | GoJudgeRunner |
go-judge over HTTP |
SubmissionRepository |
submission | PostgresSubmissionRepository |
Postgres via sqlx |
SubmissionAllowlist |
submission | Postgres adapter | Postgres |
ProblemTests |
submission | FsProblemTests<R> |
the catalog's port |
SolvedRecorder |
submission | ProgressRecorderAdapter |
the progress context |
TokenVerifier |
identity | JwksTokenVerifier |
cached JWKS, local crypto |
KeycloakAdmin |
identity | KeycloakAdminClient |
Keycloak admin REST |
BlogRepository |
blog | FileSystemBlogRepository |
disk |
LessonSource |
authoring | FsLessonSource |
disk, read uncached |
ContentEditors |
authoring | PostgresContentEditors |
Postgres |
EditRequestRepository |
authoring | PostgresEditRequests |
Postgres |
ContentForge |
authoring | GitHubForge / DryRunForge |
GitHub REST, or nothing |
LessonViewStore |
insights | PostgresLessonViews |
Postgres |
ProblemProgressStore |
progress | PostgresProblemProgress |
Postgres |
TutorClient |
tutoring | OllamaTutorClient |
Ollama |
ReadinessProbe |
platform | PgReadiness |
a Postgres ping |
Two rows are worth pausing on because they are the interesting kind of port.
ContentForge has two production adapters, which is rare here — most ports have one adapter and
a test fake. The dry-run adapter is not a mock: it is a deployment mode. Configure no forge and the
entire flow runs for real — the gate, the drift guard, the validation, the branch derivation, the
stored history — and only the final network call is skipped. That is only possible because the port
is phrased as "commit this file, open a pull request" rather than as the HTTP calls that implement
it. A technology-shaped port cannot have a credential-free twin.
SolvedRecorder is the second legitimate cross-context dependency in the codebase (the first is
described below). An accepted verdict should mark the lesson complete, and submission says exactly
that much: it declares a one-method port and knows nothing about progress tables.
Read the direction carefully: the application depends on the trait, the adapter implements it, and
nothing in application/ names a concrete adapter. Wiring happens once, in main.
Generic, not dynamic
Here is the shape that surprises people arriving from a JVM background. The use case is generic over its ports:
pub struct SubmitSolution<Repo, Tests, R: CodeRunner, List, Notify> {
repo: Arc<Repo>,
tests: Arc<Tests>,
runner: Arc<RunCodeService<R>>,
allowlist: Arc<List>,
allowlist_enforced: bool,
solved: Arc<Notify>,
}Not Arc<dyn SubmissionRepository>. The ports use native async functions in traits, and the
services are monomorphised at compile time — static dispatch, no vtable, no #[async_trait] macro
anywhere in the codebase.
The reasoning is that dyn buys runtime substitutability, and nothing here varies at runtime.
There is exactly one SubmissionRepository in a running process; the second implementation is a test
fake, and tests pick their implementation at compile time just as well. Paying a vtable indirection —
and, with async_trait, a boxed future allocation per call — to buy flexibility nobody uses is a cost
without a benefit.
💡 The honest cost. Generics propagate. SubmitSolution<Repo, Tests, R, List, Notify> means
every type that holds one carries five parameters, and the router state types get long. That is a
real readability tax, paid at every layer boundary the type crosses — and it grew: the fifth
parameter arrived with progress recording, which is exactly how this cost accrues. "Use generics
everywhere" is not the lesson. The lesson is: choose dispatch by whether anything actually varies,
and notice when the answer starts changing.
The one exception, and why it earns it
ReadinessProbe is the only port stored as a trait object:
pub readiness: Arc<dyn platform::health::ReadinessProbe>,It is also the only port that does not use async-fn-in-trait. It cannot — a trait with an
async fn is not object-safe. To be dyn, it returns a boxed future by hand:
fn ping(&self) -> Pin<Box<dyn Future<Output = Result<(), String>> + Send + '_>>;That is the trade made explicit: the boxed future is the price of dynamic dispatch, normally hidden
inside #[async_trait]. It is worth paying exactly here, because readiness is held in shared
application state used by a route that must not be generic over the whole world, and it is called
once per probe — roughly every ten seconds, not once per request.
One dyn in seventeen ports, with a written reason. That is what "no dyn where a generic suffices"
means in practice: not a ban, but a decision that has to be justified.
The purity rule is a grep
Layering that lives only in a document decays. This one is checked:
→ server domain purity (no axum/tower/hyper/tokio/sqlx/reqwest/utoipa under domain/)
ok
→ viz engine purity (no leptos/web-sys/wasm-bindgen/js-sys/gloo under viz-wasm/src/engine/)
ok
→ file-size caps (server/shared ≤ 500 · viz-wasm/web ≤ 800 · *.gen.ts exempt)
okIt runs first in CI, before compilation — it needs nothing but grep, find and wc. It is the
reason the domain is testable without a database, a browser or a network: code that cannot import the
web framework cannot depend on one.
The file-size cap in the same script is doing quieter work. A 500-line file is usually two responsibilities that have not been separated yet, so the cap turns a design smell into a build failure. It has fired for real — one client file reached 889 lines and was split along its layer seams, which is what it should have been from the start.
The exemption in that third line is worth noticing too. *.gen.ts is skipped because a generated
schema is machine output, not prose to split — and a cap that fires on generated code teaches people
to disable the cap. A gate with a false positive is a gate someone eventually routes around, so
the carve-outs matter as much as the rule.
Errors are per-context, and they are enums
Ten application-layer error enums, one per context, each a thiserror enum. No Box<dyn Error> in
any signature, and anyhow only in main.rs where the caller is a human reading a log.
The value shows up at the HTTP boundary, where mapping an error to a status is an exhaustive match. Adding a variant does not silently fall into a catch-all — it fails the build until someone decides what status it deserves:
SubmissionError variant |
Status | Why |
|---|---|---|
SubmitRequiresSignIn |
401 | anonymous caller |
NotAllowlisted |
403 | authenticated, not permitted |
NotYours |
403 | authenticated, not the owner |
NotAProblem |
404 | the lesson has no hidden suite |
UnknownSubmission |
404 | no such id |
InvalidSuite |
500 | the author wrote a bad suite — my bug, not the caller's |
InvalidSuite → 500 is deliberate and worth pausing on. A malformed test suite is not a client
error; the request was perfectly valid. Returning 400 would blame the reader for a mistake the author
made, and would hide it from the error-rate metrics that should be screaming.
The newest context makes the same argument with a different status. AuthoringError::SourceMoved
maps to 409 Conflict, not 400: a contributor who edited a lesson that has since changed on disk
did nothing wrong, and their text is not invalid. The request is simply no longer applicable, and 409
is the one status that says exactly that — which matters, because the client's correct response is
"reload and reapply", not "fix your input". A status code is the first sentence of an error message,
and picking the wrong one sends the reader to the wrong place before they have read a word.
Two of those variants are handled before the match. Why, and what does that cost?
SubmitRequiresSignIn and NotAllowlisted return early, because their responses carry a detail
and a hint that the generic tail cannot produce — the allowlist rejection interpolates the
username and suggests self-hosting. Good errors are specific, and specificity does not fit a
(status, message) tuple.
The cost is a subtle fragility: both variants also appear in the match, mapped to 500, to keep it exhaustive. Those arms are unreachable today. But delete an early return and the compiler stays silent while a 401 quietly becomes a 500 — the exhaustiveness check cannot help, because the arm still exists.
The sturdier construction is to make the match total and return the rich bodies from inside the arms, so there is exactly one place a variant's status is decided. This is a small, real design debt, and it is in the book because a chapter that only shows the parts that came out clean is not much of a design document.
A dependency that looks wrong and is not
FsProblemTests is generic over the catalog's port:
impl<R: ContentRepository> ProblemTests for FsProblemTests<R>A submission adapter depending on another context's port looks like a boundary violation. It is actually the honest modelling of a real relationship: hidden test suites are content, they live in the content tree, and the catalog already owns reading that tree safely — including the traversal guard that stops a crafted path escaping the content root.
The alternative is worse. Giving submission its own filesystem access would duplicate the resolver
and the traversal guard, and duplicated security code drifts. So submission is a customer of
catalog's supplier relationship, and it depends on the port, not the adapter — it names an
interface, and cannot see the filesystem itself.
The discipline that keeps this from becoming a mess: the dependency points at an interface, it is one direction only, and it is written down.
If ports exist to allow swapping implementations, and nothing here is ever swapped, what are the ports actually buying?
Testability and direction of dependency — which turn out to matter more than substitutability.
The swap almost never happens in production. What happens constantly is testing: every use case is exercised against a fake adapter, in-process, with no container, no network and no fixture database. The suite runs in seconds because the ports exist.
The deeper benefit is that the dependency arrow points inward. Without a port, submission would
import sqlx, and the aggregate's rules would be tangled with a specific database's types — so
changing databases would mean changing business logic. With a port, the database is a detail the
domain never learns about.
And the option is genuinely real, even if unused. The execution context is the extraction candidate precisely because its port is already a trait whose adapter already speaks HTTP to another process. The port is what makes that a wiring change rather than a rewrite. Optionality has value even when the option is never exercised — the mistake is paying unbounded cost for it, which is why the layering here is proportional rather than uniform.