go-doc/data/docs/go-bananas-architecture.md
2026-10-07 15:05:48 +02:00

16 KiB
Raw Permalink Blame History

Imported from docs/architecture.md in the go-bananas repository.


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

                          ┌────────────────────────────────────────────┐
   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:

  1. Resolve the base document.
  2. Deep-merge the requested appearance's overrides on top.
  3. Flatten to leaves, in sorted path order.
  4. Append derived tokens for values the author did not define.
  5. Emit scoped selectors: the theme's default appearance, the alternate, a .dark ancestor, and optionally prefers-color-scheme.
  6. 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.