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
- Show backend depth in Spring Boot: API design, multi-tenant data, an audit trail, caching, live push and failure handling.
- Fast flag checks: libraries evaluate flags locally in memory (target: well under 1 ms per check, no network call).
- Instant changes: a flag change reaches connected clients within about a second.
- Failure safety: if the server is down or asleep, clients keep serving their last known flags.
- 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 unreachableKey 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
projects:key,nameenvironments: per project (development,productionby default),sdk_key_hash,sdk_key_hint,versionflags: per project,key,name,description,archived_atflag_states: one row per flag × environment:enabled,version,updated_ataudit_log: actor, action, flag, environment,before/after(JSON)
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)
- Flag checks per second and time per check in the Java library (JMH)
/sdk/v1/flagslatency: 200 vs 304- Time for a change to reach 1,000 connected SSE clients (k6)
- Native image vs JVM: startup time and memory on the free instance
- Client behaviour with the server stopped: errors seen by the app (target: 0)
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.