A feature flag system built from scratch — evaluation engine, REST API, admin UI, and SDKs for JavaScript and Python.
A feature flag system lets you ship code and release it as two separate decisions. The code for a new checkout flow can sit in production, dark, behind a flag, while it's still being tested — then get turned on for 5% of users, then 50%, then everyone, without a redeploy. The same mechanism lets you target a specific segment (beta testers, enterprise accounts, a single country) or kill a feature instantly if it misbehaves, all from a UI or API call instead of a git revert.
This project implements that system end to end: a SQLite-backed data model, a deterministic evaluation engine, a REST API, a small admin UI to manage flags by hand, and SDKs so an application can check a flag with one line of code. It's built as a from-scratch counterpart to hosted products like LaunchDarkly or Unleash — the same core ideas (master switch, percentage rollout, attribute targeting, segments), implemented directly so every part of the evaluation path is inspectable.
┌───────────────────────────────────────────┐
│ Admin UI │
│ (vanilla JS SPA, dark mode) │
└─────────────────────┬───────────────────────┘
│ REST API (fetch)
┌─────────────────────▼───────────────────────┐
│ FastAPI Server │
│ ┌───────────────┐ ┌──────────────────────┐│
│ │ Flag/Rule │ │ Evaluation Engine ││
│ │ API Routers │ │ (FlagEvaluator) ││
│ └───────┬───────┘ └──────────┬────────────┘│
│ └──────────┬───────────┘ │
│ ┌───────────────────▼───────────────────────┐│
│ │ Repository Layer ││
│ │ FlagRepository RuleRepository ││
│ │ UserRepository ││
│ └───────────────────┬───────────────────────┘│
│ ┌───────────────────▼───────────────────────┐│
│ │ SQLAlchemy 2.0 ORM + SQLite ││
│ └─────────────────────────────────────────────┘│
└───────────────────────────────────────────────┘
▲ ▲
│ HTTP │ HTTP
┌────────┴────────┐ ┌────────┴────────┐
│ JS SDK │ │ Python SDK │
│ (Node / browser) │ │ (any Python app) │
└────────────────────┘ └────────────────────┘
Every layer has one job. The repository layer is the only thing that touches SQLAlchemy — it doesn't know evaluation logic exists. The evaluation engine takes a database session and a user context and returns a decision — it doesn't know about HTTP. The API routers translate HTTP into repository/engine calls and back into JSON — they don't contain business logic. The SDKs talk to the API the same way any external client would; they have no special access to the database. That separation is what makes each layer testable in isolation (see Design decisions).
FlagEvaluator.evaluate(flag_key, environment_name, user) walks a fixed decision path and returns as soon as one step produces an answer:
┌─────────────────────────────┐
│ Does FlagEnvironment exist? │
└───────────────┬───────────────┘
no │ │ yes
┌────────▼──────┐ │
│ NOT_FOUND │ │
└───────────────┘ │
┌───────▼────────┐
│ enabled==False? │
└───────┬────────┘
yes│ │no
┌─────────▼───┐ │
│ FLAG_OFF │ │
└─────────────┘ │
┌────────▼─────────┐
│ for each rule, │
│ priority ASC: │
│ segment or │
│ attribute match? │
└────────┬─────────┘
match│ │no match
┌───────────▼──┐ │
│ RULE_MATCH │ │
└──────────────┘ │
┌─────────▼─────────┐
│ bucket(user,flag) │
│ < rollout_pct? │
└─────────┬─────────┘
yes│ │no
┌───────────────▼──┐ ┌─────▼────────┐
│ PERCENTAGE_ROLLOUT│ │ DEFAULT_OFF │
└────────────────────┘ └───────────────┘
- Flag/environment lookup. If the flag doesn't exist, or was never configured for the given environment, the result is
NOT_FOUND. This is distinct fromFLAG_OFF— a typo'd flag key looks different from a flag that's intentionally disabled. - Master switch. If
FlagEnvironment.enabledisFalse, the result isFLAG_OFFregardless of any rules or rollout percentage — the switch always wins. - Rules, in priority order. Each enabled rule is either segment-based (the user must satisfy every condition in the segment) or attribute-based (a single
attribute operator valuecheck, e.g.country equals "CL"). The first rule that matches returnsRULE_MATCHimmediately — rules are not combined with AND/OR across each other, only within a segment. - Percentage rollout. If no rule matched,
sha256(f"{flag_key}:{user_id}")maps the user into a bucket from 0–99. If that bucket is belowrollout_percentage, the result isPERCENTAGE_ROLLOUT. The same user always lands in the same bucket for a given flag, so rollout membership is stable across requests without storing anything per-user. - Default. Otherwise,
DEFAULT_OFF.
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
alembic upgrade head
uvicorn app.api.main:app --reloadThe server seeds three environments (production, staging, development) on startup if they don't already exist.
http://localhost:8000
Create a flag, flip it on for an environment, add a targeting rule, and try it out in the Evaluate playground — no separate build step, the UI is plain HTML/CSS/JS served as static files.
const { FeatureFlagsClient } = require('@jpurtu/feature-flags-sdk');
const client = new FeatureFlagsClient({
baseUrl: 'http://localhost:8000',
environment: 'production',
});
const enabled = await client.isEnabled('dark-mode', { user_id: 'user-123', plan: 'pro' });from feature_flags_sdk import FeatureFlagsClient
with FeatureFlagsClient(base_url="http://localhost:8000", environment="production") as client:
enabled = client.is_enabled("dark-mode", {"user_id": "user-123", "plan": "pro"})Master switch. Every flag has an independent on/off state per environment (FlagEnvironment.enabled). A flag can be on in development, off in staging, and off in production at the same time — the same flag key, three separate states.
Percentage rollout. Setting rollout_percentage to 10 exposes the flag to roughly 10% of users, chosen by hashing flag_key:user_id with SHA256 and taking the result mod 100. Because the hash is deterministic, a given user either is or isn't in the rollout consistently — no per-user state needs to be stored, and raising the percentage from 10 to 20 only adds new users, it never removes existing ones.
Attribute targeting. A rule like country equals "CL" or plan in_list "pro,enterprise" checks a single attribute off the user context against a value, using one of ten operators (equals, not_equals, contains, not_contains, in_list, not_in_list, greater_than, less_than, starts_with, ends_with).
Segment targeting. A segment is a named, reusable set of attribute conditions (e.g. "beta testers" = plan equals enterprise AND country in_list "US,CA"). A rule can point at a segment instead of a single attribute; the user must satisfy every condition in the segment for the rule to match. Segments are defined once and can back rules on multiple flags.
Rule priority. Rules on a flag/environment are evaluated in priority order (lower first), and the first one that matches wins — evaluation stops there. A disabled rule (enabled=False) is skipped entirely, as if it didn't exist.
All routes are mounted under /api. Full interactive docs (generated by FastAPI) are available at http://localhost:8000/docs once the server is running.
| Method | Path | Description |
|---|---|---|
POST |
/api/flags |
Create a flag |
GET |
/api/flags |
List all flags |
GET |
/api/flags/{flag_key} |
Get a flag by key |
PATCH |
/api/flags/{flag_key} |
Update a flag's name/description |
DELETE |
/api/flags/{flag_key} |
Delete a flag |
GET |
/api/flags/{flag_key}/environments/{env_name} |
Get a flag's state in an environment |
PATCH |
/api/flags/{flag_key}/environments/{env_name} |
Update enabled/rollout_percentage |
POST |
/api/environments |
Create an environment |
GET |
/api/environments |
List all environments |
POST |
/api/flags/{flag_key}/environments/{env_name}/rules |
Create a targeting rule |
GET |
/api/flags/{flag_key}/environments/{env_name}/rules |
List rules, ordered by priority |
PATCH |
/api/rules/{rule_id} |
Update a rule |
DELETE |
/api/rules/{rule_id} |
Delete a rule |
POST |
/api/segments |
Create a segment |
GET |
/api/segments |
List all segments |
POST |
/api/segments/{segment_id}/rules |
Add a condition to a segment |
GET |
/api/segments/{segment_id}/rules |
List a segment's conditions |
POST |
/api/evaluate |
Evaluate one flag for a user (used by SDKs) |
POST |
/api/evaluate/batch |
Evaluate multiple flags for a user |
| Method | Returns | On error |
|---|---|---|
isEnabled(flagKey, user?) |
Promise<boolean> |
false |
getFlags(flagKeys, user?) |
Promise<Record<string, boolean>> |
false per flag |
evaluate(flagKey, user?) |
Promise<EvaluationResult | null> |
null |
evaluateBatch(flagKeys, user?) |
Promise<Record<string, EvaluationResult | null>> |
null per flag |
Constructor options: baseUrl, environment, defaultUser, timeoutMs (default 5000), cacheTtlMs (default off), onError.
| Method | Returns | On error |
|---|---|---|
is_enabled(flag_key, user=None) |
bool |
False |
get_flags(flag_keys, user=None) |
dict[str, bool] |
False per flag |
evaluate(flag_key, user=None) |
dict | None |
None |
evaluate_batch(flag_keys, user=None) |
dict[str, dict | None] |
None per flag |
close() |
None |
— |
Constructor options: base_url, environment, default_user, timeout (default 5.0), retries (default 1), cache_ttl (default off), on_error.
Both SDKs merge defaultUser/default_user under the per-call user argument, retry once on a network-level failure (never on a 4xx/5xx response), and never raise/reject to the caller — see each SDK's own README (linked above) for the full contract.
| Reason | Meaning |
|---|---|
NOT_FOUND |
The flag or environment doesn't exist |
FLAG_OFF |
The flag exists but is disabled in this environment |
RULE_MATCH |
A targeting rule (attribute or segment) matched the user |
PERCENTAGE_ROLLOUT |
The user's deterministic bucket fell within the rollout percentage |
DEFAULT_OFF |
The flag is on, but no rule matched and the user fell outside the rollout |
SQLite. The data volume here — flags, rules, segments — is small and read-heavy, and SQLite gives zero-config portability: the whole system runs from alembic upgrade head with no external service to stand up. The repository layer is the only place that knows about SQLAlchemy, so swapping in Postgres later is a connection-string and migration-dialect change, not a rewrite.
A synchronous, stateless evaluation engine. FlagEvaluator.evaluate() takes a Session and a SDKUser and returns an EvaluationResult — no caching, no background state, no async. That makes it trivial to unit test (call it, assert the result) and easy to reason about (the five-step algorithm above is the whole implementation), at the cost of a database round-trip per evaluation. For this project's scale that trade is worth it; a production system with very high QPS would likely add a read-through cache in front of it without changing the algorithm itself.
SHA256 for rollout bucketing. Hashing flag_key:user_id gives a deterministic, uniformly distributed bucket without persisting which users are "in" a rollout. Raising the percentage is monotonic — everyone previously included stays included — which is the property that makes gradual rollouts safe to reason about.
Zero dependencies in both SDKs. The JavaScript SDK uses native fetch/AbortController; the Python SDK uses urllib.request. A flag-evaluation client is exactly the kind of thing that ends up embedded in every service in an organization — it shouldn't be the thing that causes a dependency conflict or a supply-chain concern three layers down.
Repositories separate from the evaluation engine. FlagRepository/RuleRepository/UserRepository know how to read and write rows; FlagEvaluator knows the targeting algorithm and calls the repositories to get the data it needs. Neither depends on the API layer. This is what let the engine (Phase 2) be built and fully tested before the REST API (Phase 3) existed at all.
pytest tests/ -v # 95 tests: repositories, evaluation engine, REST APIfeature-flags/
├── app/
│ ├── database.py # engine, SessionLocal, Base, get_db
│ ├── models/ # Flag, Environment, FlagEnvironment, Rule, Segment, SegmentRule, SDKUser
│ ├── repositories/ # FlagRepository, RuleRepository, UserRepository
│ ├── engine/ # FlagEvaluator, operators, SHA256 hasher
│ └── api/
│ ├── main.py # FastAPI app, CORS, static mount, startup seeding
│ ├── dependencies.py # get_db, get_evaluator
│ ├── routers/ # flags, environments, rules, evaluate
│ └── schemas/ # Pydantic request/response models
├── alembic/ # migrations (001_initial_schema creates all tables)
├── static/ # admin UI: index.html, css/, js/ (vanilla, no build step)
├── sdks/
│ ├── javascript/ # @jpurtu/feature-flags-sdk
│ └── python/ # feature_flags_sdk
├── tests/ # 95 tests across repositories, engine, and API
├── data/ # gitignored — feature_flags.db lives here
├── alembic.ini
└── requirements.txt
MIT