16 KiB
Architecture
How go-bananas is put together, and why each decision was made. The reasoning behind the load-bearing choices lives in the ADRs; this document is the map.
Contents
- The shape of the system
- Packages
- The dependency rule
- Request lifecycle
- The token engine
- The render cache
- Publishing
- The database
- The stack
- Cross-cutting concerns
- Performance
- What is deliberately absent
The shape of the system
┌────────────────────────────────────────────┐
browser ──────────────▶│ /rt/v1 runtime public, cached │
<link>, fetch, import │ /api/v1 admin authenticated │
│ /auth/* OIDC login flow │
│ /metrics Prometheus │
│ /healthz /readyz /livez │
│ /* admin SPA (static) │
└───────────────────┬────────────────────────┘
│
chi router (v5)
│
┌─────────────────────┼─────────────────────┐
│ │ │
┌─────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ middleware │ │ router │ │ handlers │
│ 13 layers │ │ route tree │ │ no logic │
└─────┬──────┘ └──────┬──────┘ └──────┬──────┘
│ │ │
┌─────▼──────┐ │ ┌──────▼──────┐
│ auth │ │ │ tokenengine │
│ OIDC·keys │ │ │ colour·CSS │
│ ·RBAC │ │ │ ·validate │
└─────┬──────┘ │ └──────┬──────┘
│ │ │
└──────────┬──────────┴─────────────────────┘
│
┌─────────▼──────────┐
│ domain │ entities, invariants
└─────────┬──────────┘ errors, validation rules
│
┌─────────▼──────────┐
│ store │ the only package with SQL
│ SQLite (WAL) │
└─────────<E29480><E29480>──────────┘
│
SQLite file on disk
Packages
| Package | Responsibility | May import |
|---|---|---|
cmd/go-bananas |
main(). Signal handling and an exit code. Nothing else. |
app |
app |
Dependency injection, the process lifecycle, static file serving. | everything except cmd |
config |
Environment parsing, validation, defaults. | stdlib |
domain |
Entities, invariants, sentinel errors, slug rules. | stdlib |
tokenengine |
Colour maths, validation, CSS and JSON rendering, palettes. | domain |
store |
The only package that writes SQL. Migrations, repositories. | domain |
auth |
OIDC, session cookies, role resolution. | config, domain |
httpapi |
Routing, middleware, request and response encoding. | domain, store, tokenengine, auth, config |
logging |
slog configuration and the Logger interface. |
stdlib |
seed |
The four built-in themes, and their reconciliation rules. | domain, tokenengine |
observability |
Reserved for OpenTelemetry wiring. | — |
The dependency rule
cmd → app → httpapi → auth → config
↓ ↓
tokenengine store
↓ ↓
domain ←──┘
Enforced by review, not by tooling, because a linter for this is more trouble than
it is worth. The rule that matters most: store is the only package that contains
SQL. A handler never writes a query, which is what makes replacing the database a
change to one package.
Request lifecycle
A runtime stylesheet request, which is the overwhelming majority of traffic:
1. RequestIDMiddleware assign or propagate a correlation id
2. RecoverMiddleware a panic becomes a 500, not a dead process
3. LoggingMiddleware one structured line, on completion
4. TrustedProxyMiddleware resolve the client address, believing X-Forwarded-For
only from a configured proxy
5. CORSMiddleware preflight, for the admin API
6. SecurityHeaders CSP, nosniff, frame-deny, HSTS when TLS
7. BodyLimitMiddleware 2 MiB cap
8. AuthMiddleware resolve the caller; never rejects
9. MetricsMiddleware record route pattern, status and latency
10. RateLimitMiddleware fixed window, keyed by route class and client
11. Handler
store.Get(slug) ← the only database access, on a cache miss
cache.CSS(...) ← one map lookup on a cache hit
renderDeclarations(...) ← only on a miss
writeCacheable(...) ETag, Cache-Control, 304 when unchanged
Layers 1–3 are outermost so that everything after them is covered by the panic
recovery. Layer 8 authenticates without rejecting, so a public endpoint can serve
while a protected one responds 401 with a proper JSON body rather than a bare
net/http rejection.
The steady-state cost is one map lookup, one string write, and no database access.
The token engine
Pure functions over a token document. No I/O, no global state, safe for concurrent use — which is what lets the HTTP layer cache rendered themes aggressively and pre-warm them at start-up.
Validation
Validate(doc) → ValidationResult returns every issue, ordered by severity then
path so the output is deterministic. Three tiers:
- Errors block publication: an unknown group, a value of the wrong type, a malformed colour, a reserved key used as a group.
- Warnings do not block: a missing recommended token, no dark scheme declared.
- Info is advisory.
Rendering
Render(theme, options) → string produces the stylesheet:
- Resolve the base document.
- Deep-merge the requested appearance's overrides on top.
- Flatten to leaves, in sorted path order.
- Append derived tokens for values the author did not define.
- Emit scoped selectors: the theme's default appearance, the alternate, a
.darkancestor, and optionallyprefers-color-scheme. - Emit the component layer inside
@layer bananas.
Sorted path order is not cosmetic. It makes the output byte-stable, so the ETag is stable, so a re-render of an unchanged theme produces a cache hit at the CDN rather than a 200 with a fresh body.
Colour
Everything the engine needs is computed from WCAG 2.1 relative luminance:
ContrastRatio, BestForeground (black or white, whichever wins), Mix with
straight-alpha compositing, HSLHex, and GenerateRamp — the palette builder behind
the editor's one-click ramp and the reason the built-in themes are tonally consistent.
Non-#rrggbb syntaxes (oklch(), color(), CSS keywords) parse, render faithfully,
and are excluded from contrast computation rather than guessed at.
The render cache
The single most important performance component.
| Key | slug | appearance | variant |
| Value | the rendered string, plus a content-hash ETag |
| Invalidated | on write, for exactly one theme's entries |
| Eviction | none: themes are bounded by human authorship |
| Pre-warmed | both appearances × all three variants, at start-up |
Rendering a theme traverses its whole token document. Serving the cached string is one map lookup. Because the key includes the version, publishing invalidates precisely that theme and nothing else, and a re-render after a restart produces byte-identical output, so a CDN's existing entry stays valid.
The ETag is a content hash rather than a version counter. Two themes with different slugs can produce byte-identical CSS, and a content hash lets a shared CDN deduplicate them. It is also self-correcting: a validator can never disagree with the body it describes.
Publishing
POST /api/v1/themes/{slug}/publish
│
├─ 1. load the theme
├─ 2. Validate(tokens) ← errors block; warnings do not
├─ 3. transaction:
│ UPDATE themes SET status='published', version=version+1, …
│ INSERT INTO theme_versions … ← immutable snapshot
├─ 4. cache.Invalidate(slug) ← exactly this theme
└─ 5. audit append ← actor, action, target, IP
Every mutation bumps version, because the version is the cache key for the rendered
CSS and must change whenever the rendered output could.
History is append-only. A rollback restores an old snapshot as a new version rather than rewinding, so an application already deployed against version 7 can never have version 7 silently taken away.
The database
See ADR 0001 for the full argument. In brief: a single
file, WAL journalling, synchronous=NORMAL, a 16 MiB page cache, mmap reads, and a
deliberately small connection pool.
Migrations are embedded with go:embed, applied at start-up, and tracked in a
schema_migrations table. They are idempotent by construction.
Timestamps are TEXT in RFC 3339, which sorts lexicographically, so ORDER BY and range
filters need no date parsing and no timezone ambiguity.
Token documents are JSON columns. They are always read and written whole, never queried into — which is exactly the case where a JSON column is the right shape and a join table would be ceremony with no benefit.
The stack
| Layer | Choice | Why |
|---|---|---|
| Language | Go 1.27 | Static binaries, trivial cross-compilation, a good standard library |
| Router | chi v5 | Small, net/http-compatible, middleware composition, no magic |
| Database | modernc.org/sqlite |
Pure Go: no cgo, static binary, no libc |
| OIDC | coreos/go-oidc v3 |
Discovery-driven: provider-agnostic from one issuer URL |
| Session | HMAC-signed cookie | Replica-agnostic, no shared state |
| Logging | log/slog |
Standard library; no vendor in the graph |
| Metrics | hand-written collector | ~200 lines, no dependency in a security-sensitive service |
| Frontend | React 19 | The most common consumer, and the admin UI's own choice |
| Build | Vite 8 | Fast, and the SDK is served rather than published |
| Styling | Tailwind 4 | The admin UI consumes runtime variables through Tailwind's namespaces |
| Data | TanStack Query 5 | Server-state management without inventing a cache |
| Validation | Zod 4 | Runtime parsing at the network boundary |
| Routing | React Router 7 | Data router; lazy route splitting |
| Language | TypeScript 6.0.3 | Newest release with the compiler API the linters need — ADR 0004 |
Deliberately absent: no ORM (the queries are simple and an ORM would hide them), no DI framework (constructor injection is enough), no GraphQL (a stylesheet is a GET), no Redis (the cache is process-local), no component library in the admin UI (it would fight the runtime variables).
Cross-cutting concerns
Authentication
Two mechanisms: an OIDC session cookie, and a development bypass that refuses to initialise outside development.
The session cookie is the only credential. There is no bearer-token path, so a
request carrying an Authorization header is treated exactly like one without: a
stale or forged token cannot grant anything. Machine credentials were removed
because there is no machine caller left — the runtime API is public and the
administration API is for people.
Roles come from provider groups. The mapping fails closed: a user in no configured group is a viewer. A mapping mistake must grant nothing, not everything.
Authorisation
Enforced in the server on every admin route. The UI hides what the caller cannot do, as a convenience — never as the control.
Audit
Every mutation records actor, action, target, timestamp and IP. The actor comes from the authenticated identity, never from the request body. A write failure is logged, not propagated: losing an audit row is bad, failing a publish because of it is worse.
Observability
Structured JSON logs with a correlation id on every line. Prometheus metrics with
request counts by route pattern (never by concrete path — a thousand theme slugs must
not become a thousand time series), a latency histogram, cache hit rate, and Go
runtime gauges. Three probes: /livez for liveness, /healthz for liveness with
build information, /readyz for readiness, which is the only one that touches the
database.
Errors
One vocabulary. The domain returns sentinel errors; the store translates driver
errors once; writeProblem maps sentinels to status codes once; the frontend client
turns a non-2xx into an ApiError carrying the code and the per-field messages. A
handler never chooses a status code itself, and no repository matches an error string.
Performance
What the runtime path actually costs, with a warm cache:
| Operation | Cost |
|---|---|
| Stylesheet hit | one map lookup + one string write |
| Stylesheet miss | token document traversal, ~100 µs for 200 tokens |
| Manifest | one query, small result, cached by the CDN for 30 s |
| Admin list | one indexed query |
| Save | one transaction, two writes, one cache invalidation |
Measured on the built-in themes, a cold stylesheet render is sub-millisecond, which is why the render cache is an optimisation rather than a requirement. It exists to remove the work entirely under load, and to make the first request after a deploy fast.
The design consequence: the hot path is not the database. That is what makes an embedded database viable here, and it is why the connection pool is small on purpose.
What is deliberately absent
| Not used | Why |
|---|---|
| An ORM | The queries are ten lines each; an ORM would hide the SQL that matters for the hot path |
| Redis | The render cache is process-local and loses nothing on restart. A second service to operate for a cache is a bad trade — until multi-node, where it becomes a good one |
| DI framework | Constructor injection in app.New is clearer than a container |
| GraphQL | A stylesheet is a GET. A schema, a server and a client library to deliver a JSON document is a poor trade |
| A component library in the admin UI | It would fight the runtime variables, which are the point |
| Server-side rendering of tokens per request | Every consumer would implement its own fetcher, cache, fallback and revalidation, and they would all get it subtly wrong |
| A message queue | The write path is a human clicking publish |
| Feature flags | The chart covers configuration; a flag system for four booleans is a second way to be wrong |
Each of these would be the right answer under different constraints, and the ADR process is how one of them becomes the answer here.