Architecture and API

A self-hosted feature-flag service: a Spring Boot API, client libraries that evaluate flags in memory, live updates over Server-Sent Events, and an Angular dashboard. It's built and hosted for ₹0, with no payment card on any provider.

For what it does in plain language, see the user guide.

Working name. Rename freely before the first public release (check the GitHub org, npm and Maven Central first).


Goals

  1. Show backend depth in Spring Boot: API design, multi-tenant data, an audit trail, caching, live push and failure handling.
  2. Fast flag checks: libraries evaluate flags locally in memory (target: well under 1 ms per check, no network call).
  3. Instant changes: a flag change reaches connected clients within about a second.
  4. Failure safety: if the server is down or asleep, clients keep serving their last known flags.
  5. Free forever: free tiers only, no card on file, and easy to move if a provider changes its terms.

Architecture

 Dashboard (Angular, Cloudflare Pages)
        │  admin API (Bearer GitHub sign-in session, or the admin token)
        ▼
 Switchyard server (Spring Boot 4, GraalVM native image, Render free)
        │  Postgres (Neon free) · Flyway migrations · audit log
        │
        ├── GET /sdk/v1/flags      full flag set, ETag + 304 for cheap polling
        └── GET /sdk/v1/stream     SSE: "version" event on every change, ping every 25 s
              ▲
   Java / JS client libraries: cache flags in memory, evaluate locally,
   poll as a fallback, keep the last good copy when the server is unreachable

Key design decisions

Decision Why
JDBC (JdbcClient), not JPA Lower memory on a 512 MB instance, faster native builds, explicit SQL
Per-environment version counter Every change bumps it; it doubles as the ETag, so an unchanged poll costs one indexed lookup and returns 304 with no body
SDK keys stored as SHA-256 hashes A database leak doesn't leak working keys; keys are shown once when created or rotated
Admin token from an environment variable Simplest secure start, and still there for scripts and first setup. People sign in with GitHub instead (phase 5b)
GitHub sign-in handled by the server (OAuth code flow) Only the server holds the client secret. It checks an allowlist of GitHub logins (anyone can have an account), stores just a hash of each session token, and drops GitHub's access token after one call. The state is single-use and tied to the browser by a cookie
Session token handed over in the URL fragment …/auth/callback#token=… never reaches a server log or a Referer; the dashboard moves it into sessionStorage and out of history at once. A cookie wouldn't work: the dashboard and API are on different sites, where browsers block third-party cookies
Browsers get values, not rules An SDK key can read every rule (and whatever they list), so browsers use a public client-side ID that only returns the values of flags marked client_side, evaluated by the server for the context the page sends. The server evaluates with the Java client's own evaluator, so a browser gets what an SDK would
Roles checked in the services, not the URLs Every change (including undo and anything added later) goes through the same AccessGuard call before it writes, so a new endpoint can't forget a check. The dashboard hides what a role can't do, but the server decides
Undo replays the audit entry's before Every change already records what it replaced, so undo is "apply before", audited like any change and linked by reverts_id. Refuses (409) when there's nothing left to undo
CORS filter ahead of authentication The dashboard runs on its own origin: preflights carry no token, and a 401 must carry CORS headers or the browser hides it from the dashboard
RFC 9457 problem details for errors One consistent error format for every client
SSE, not WebSockets, for live updates Updates only flow server → client; browsers reconnect automatically after the free server wakes
Streams carry versions, not flags event: version / data: 42; clients fetch /sdk/v1/flags when it differs from theirs. One code path for flag data, and catching up after a reconnect is automatic
In-process change events, not Postgres LISTEN/NOTIFY LISTEN holds a connection open forever, which would keep a scale-to-zero database (Neon free) awake around the clock. Fine for one instance; LISTEN/NOTIFY is the path to several
SDK reads cached in memory Version, payload (immutable per version) and SDK key → environment, updated by after-commit events, the version before the stream announces it. The fetch burst after a change costs no database work

Data model

Phase 3 added rules and fallthrough (JSON) to flag_states: targeting rules and percentage rollouts, evaluated by the clients as spec/EVALUATION.md defines.

Phase 5b added users (GitHub id, login, name, avatar), sessions (token hash, expiry), sign_in_attempts (OAuth state hash, where to return) and audit_log.reverts_id.

Phase 9 added users.role (viewer, editor, admin) and environments.is_protected (Production starts protected), then environments.requires_approval, pending_changes (change requests and scheduled changes: pending_approval → scheduled → applied, or rejected / cancelled / failed), api_tokens (hashed, with a role and an optional project) and webhooks.

Phase 8 added environments.client_id (public) and flags.client_side.

Phase 7 added flags.type and flags.variations (multivariate flags), flag_states.off_variation and flag_states.prerequisites, and segments (per project: included and excluded keys, rules). Segment changes bump every environment of the project.

API

Admin (Authorization: Bearer <session token from GitHub sign-in>, Bearer <API token> or Bearer <SWITCHYARD_ADMIN_TOKEN>):

Method Path Purpose
POST /api/projects Create a project with development and production environments; returns their SDK keys once
GET /api/projects List projects
GET /api/projects/{project} One project with its environments
POST /api/projects/{project}/environments/{env}/sdk-key Rotate an SDK key (the old key stops working immediately)
POST /api/projects/{project}/flags Create a flag (off in every environment)
GET /api/projects/{project}/flags List flags with their state per environment
GET /api/projects/{project}/flags/{flag} One flag
PATCH /api/projects/{project}/flags/{flag} Rename or redescribe
PUT /api/projects/{project}/flags/{flag}/environments/{env} Turn on or off ({"enabled": true})
PUT /api/projects/{project}/flags/{flag}/environments/{env}/targeting Replace rules and fallthrough ({"rules": [...], "fallthrough": {...}}, format in spec/EVALUATION.md)
DELETE /api/projects/{project}/flags/{flag} Archive (removed from SDK payloads)
GET /api/projects/{project}/audit Change history, newest first
POST /api/projects/{project}/segments Create an (empty) segment
GET /api/projects/{project}/segments List segments, each with the flags that use it
GET /api/projects/{project}/segments/{segment} One segment
PUT /api/projects/{project}/segments/{segment} Replace its name, description, included, excluded and rules
DELETE /api/projects/{project}/segments/{segment} Delete it (409 while a flag uses it)
POST /api/projects/{project}/audit/{id}/revert Undo a switch, a targeting change or a details edit; returns the flag
GET /api/me Who the token belongs to (method: github or admin-token) and their role
GET /api/members Everyone who has signed in with GitHub, with their roles (admins)
PUT /api/members/{login}/role {"role": "viewer" | "editor" | "admin"} (admins; not your own)
PUT /api/projects/{project}/environments/{env}/protection {"protected": true}: only admins may then change it (admins)
POST /api/projects/{project}/environments Add an environment: {"key", "name", "protected"?, "requiresApproval"?, "copyFrom"?}; returns its SDK key once (admins)
PATCH /api/projects/{project}/environments/{env} {"name"?, "requiresApproval"?} (admins)
DELETE /api/projects/{project}/environments/{env} Delete it and its SDK key; refused while protected, or for a project's last environment (admins)
POST /api/projects/{project}/changes Ask for a change: {"flag", "environment", "enabled" or "targeting", "at"?, "comment"?}. Waits for approval where it's required, then for at if given
GET /api/projects/{project}/changes?status=open|all&flag= Changes waiting for an approval or a time (open, the default), or every one
GET /api/projects/{project}/changes/{id} One change
POST /api/projects/{project}/changes/{id}/approve {"comment"?}: someone other than the requester, with the rights to make the change. Applies it now, or at its time
POST /api/projects/{project}/changes/{id}/reject {"comment"?}
POST /api/projects/{project}/changes/{id}/cancel The requester, or an admin, while it waits
GET /api/tokens API tokens (admins; not with an API token)
POST /api/tokens {"name", "role", "project"?, "expiresInDays"?}; returns the token once
DELETE /api/tokens/{id} It stops working at once
GET /api/projects/{project}/webhooks Webhooks with their last delivery (admins)
POST /api/projects/{project}/webhooks {"name", "url", "format": "json" | "slack"}; returns the signing secret once
PATCH /api/projects/{project}/webhooks/{id} {"name"?, "url"?, "enabled"?}
DELETE /api/projects/{project}/webhooks/{id}
POST /api/projects/{project}/webhooks/{id}/test Sends a test now: {"ok", "status", "error"}
POST /api/sign-out End a GitHub session now

Browsers (public client-side ID in the path; CORS open to every origin):

Method Path Purpose
POST /client/v1/{clientId}/evaluate Body {"key": …, "attributes": {…}}; returns the browser flags' values (value, variation, reason) and the environment's version
GET /client/v1/{clientId}/stream Server-Sent Events, as for SDKs: evaluate again when the version changes

Sign-in (no token):

Method Path Purpose
GET /auth/config {"github": true} when GitHub sign-in is set up
GET /auth/github/start?redirect=<dashboard>/auth/callback Sends the browser to GitHub; redirect must be on a dashboard origin
GET /auth/github/callback Where GitHub sends it back; redirects to the dashboard with #token=… or #error=…

SDK (Authorization: Bearer <sdk key>):

Method Path Purpose
GET /sdk/v1/flags Every active flag for the key's environment, with ETag; send If-None-Match to get 304 when nothing changed
GET /sdk/v1/stream Server-Sent Events: event: version with the current version on connect and after every change, :ping every 25 s. Closes when the key is rotated; 503 with Retry-After at the connection limit

Phases

# Phase Done when Status
1 Server: on/off flags + API Projects, environments, flags, toggles, SDK endpoint with ETag, audit log, integration tests on real Postgres Done
2 Java client library switchyard-java: fetch, cache, local evaluation, polling with ETag, keeps last good flags on failure; published to Maven Central Done, except publishing: needs a licence choice and the release secrets (the signing key exists)
3 Targeting + percentage rollouts Rules (in, not_in, starts_with, ends_with, contains), MurmurHash3 bucketing so a user stays in their bucket; shared test vectors for every library; client-vs-server contract test Done
4 Live updates (SSE) Changes reach 1,000 simulated clients in under a second; reconnect with backoff Done: p95 351–597 ms for 1,000 real clients
5a Dashboard Angular UI: projects, flags, toggles per environment, targeting editor, SDK key rotation, history; signs in with the admin token; CORS for its origin Done; tested in a browser against a real server
5b User accounts + rollback Sign in with GitHub (free OAuth), audit entries name the user, one-click revert of a change from the history Done; tested against a fake GitHub. Using it for real needs a GitHub OAuth app (steps)
6 JS library, demo app, hosting, numbers Native image deployed, demo app live, k6 load tests, write-up with measured results Code done: switchyard-js (passes the shared vectors), the demo, the image + Render Blueprint + deploy guide, JMH and k6. Going live needs your Neon, Render and Cloudflare accounts
7 Flag model Multivariate flags (string, number, JSON) with weighted splits; segments; prerequisites; number, version, date and regex operators; 52 new shared vectors, passed by both clients Done
8 Reach OpenFeature providers for Java and JavaScript; browser evaluation with public client-side IDs; switchyard-web with React hooks; evaluation reasons in both clients Done
9 Governance Roles (viewer, editor, admin) and protected environments; approval requests, scheduled changes, webhooks and Slack, scoped API tokens, custom environments Done: enforced in the services, with integration tests for each, and the dashboard's Changes, API tokens and Settings pages

Free hosting (verify each limit when deploying, and record the date)

Part Service Card? Behaviour at the limit
API Render free web service (prebuilt native image from GHCR) No Sleeps after ~15 min idle; the native image wakes in well under a second once the instance is up
Database Neon free Postgres No Scales to zero when idle and wakes on the next query
Dashboard Cloudflare Pages No Static files, unlimited
CI + native build GitHub Actions No Unlimited for public repos. This repo is private: 2,000 minutes a month, and each merge to main uses about 9 (the native build takes ~8). Making the repo public removes the limit
Container image GitHub Container Registry (public image) No Free for public images
Libraries Maven Central (io.github.nikolaviews), npm No Free
Errors / uptime Sentry free, UptimeRobot free No Stops collecting at the cap

Avoid: Render's free Postgres (expires after about 30 days) and Supabase free (pauses after a week idle) for this project.

Numbers to measure (for the write-up)

Measured so far

What Result How
Java client: first fetch to ready 78 ms Local server on JVM, awaitReady
Java client: flag check (JMH) on/off 9.5 ns · 10% rollout 48 ns · three rules then a rollout 139 ns · the same from 8 threads 220 ns benchmarks/jmh: average time, 1 fork, 5 × 1 s after 3 warm-ups; laptop with 12 threads
JS client: flag check on/off 4 ns · 10% rollout 181 ns · three rules then a rollout 282 ns npm run bench in clients/js: 5 M checks after 1 M warm-up, Node 24. Encoding into a reused buffer took the rollout from 493 ns
Java client: change reaches the app (polling) 0.94 s Toggle via admin API, client polling every 1 s, change listener timestamp
Change reaches 1,000 clients over SSE, SDK reads hitting the database p50 1,125 ms · p95 1,940 ms · max 2,006 ms StreamLoadTests: 1,000 real SwitchyardClients, server and clients on one laptop
Same, with SDK reads cached in memory p50 225–564 ms · p95 351–597 ms · max 358–601 ms (3 runs) Same test; the 1,000 simultaneous fetches no longer queue for 5 database connections
1,000 clients connect and sync 1.4–4.3 s Same test
Server native image build 7 min 46 s on GitHub Actions; 2 min 24 s in Docker on a laptop (12 threads, 6.3 GB) GraalVM 25
Native server image 281 MB image · starts in 0.65 s · 68 MiB memory after start server/Dockerfile (Debian slim base) against local Postgres, docker stats
Change reaches the demo page under 1.5 s Targeting change via the admin API → stream → JS client → page, checked 1.5 s later
Dashboard first load 147 kB transferred (626 kB raw) Production build with Angular Material; each page is loaded only when opened. Was 76 kB before Material

To verify on Render (phase 6): whether an open SSE stream counts as traffic for the free tier's 15-minute sleep timer. Run the load test with ./mvnw verify -Dtest=StreamLoadTests -Dswitchyard.load=true.