An experimentation platform for managing feature flags and A/B tests with deterministic, low-latency variant assignment.
The platform is split into two planes:
- Control Plane (
experiment-service) β CRUD operations for experiments, variants, allocations, and targeting rules. Writes to Postgres and publishes compiled config snapshots to S3 (MinIO locally). - Decision Plane (
decision-service) β Stateless, low-latency variant assignment. Reads config from an in-memory cache populated by polling S3. - API Gateway (
api-gateway) β Thin reverse proxy that routes external traffic to the appropriate service. - Dashboard (
dashboard) β Web UI for creating environments, managing experiments, and publishing configs.
- TypeScript with strict mode, ESM, NodeNext resolution
- Fastify for HTTP servers
- Prisma for database access (experiment-service only)
- S3 (MinIO locally) for config snapshot storage and distribution
- Docker Compose for local infrastructure (Postgres, MinIO)
- pnpm workspaces for monorepo management
See each service's README for more detail.
| Gateway path | Upstream |
|---|---|
/api/environments/* |
experiment-service:3001/environments/* |
/api/experiments/* |
experiment-service:3001/experiments/* |
/api/audiences/* |
experiment-service:3001/audiences/* |
/api/decide* |
decision-service:3002/decide* |
/health |
Aggregated health from both services |
- Node.js >= 20
- pnpm
- Docker and Docker Compose
pnpm installdocker compose upcp experiment-service/.env.example experiment-service/.envThis provides the local Postgres connection string used by Prisma and experiment-service.
pnpm --filter experiment-service run db:pushpnpm devThis starts all backend services plus the dashboard concurrently with hot-reload. You can also run them individually:
pnpm dev:experiment # starts on :3001
pnpm dev:decision # starts on :3002
pnpm dev:gateway # starts on :3000
pnpm dev:dashboard # starts on :3100After startup:
- API Gateway:
http://localhost:3000 - Dashboard UI:
http://localhost:3100
Run all unit tests across workspace packages:
pnpm test:unitRun end-to-end integration tests:
pnpm test:integrationIntegration test prerequisites:
- Postgres and MinIO running (
docker compose up) experiment-servicerunning on:3001decision-servicerunning on:3002
Below is a complete walkthrough: create an environment, set up an experiment with variants and allocations, publish the config, and request a decision.
curl -s -X POST http://localhost:3000/api/environments \
-H "Content-Type: application/json" \
-d '{"name": "prod"}' | jqSave the returned id β you'll need it as ENV_ID below.
curl -s -X POST http://localhost:3000/api/experiments \
-H "Content-Type: application/json" \
-d '{
"key": "checkout-button-color",
"name": "Checkout Button Color Test",
"description": "Test whether a green button improves checkout conversion",
"environmentId": "ENV_ID"
}' | jqSave the returned id as EXP_ID.
# Control variant
curl -s -X POST http://localhost:3000/api/experiments/EXP_ID/variants \
-H "Content-Type: application/json" \
-d '{
"key": "control",
"name": "Blue Button",
"payload": {"color": "#0066cc"}
}' | jq
# Treatment variant
curl -s -X POST http://localhost:3000/api/experiments/EXP_ID/variants \
-H "Content-Type: application/json" \
-d '{
"key": "treatment",
"name": "Green Button",
"payload": {"color": "#00cc66"}
}' | jqSave the returned variant id values as CONTROL_ID and TREATMENT_ID.
Allocations map buckets (0-9999) to variants. A 50/50 split:
curl -s -X PUT http://localhost:3000/api/experiments/EXP_ID/allocations \
-H "Content-Type: application/json" \
-d '{
"allocations": [
{"variantId": "CONTROL_ID", "rangeStart": 0, "rangeEnd": 4999},
{"variantId": "TREATMENT_ID", "rangeStart": 5000, "rangeEnd": 9999}
]
}' | jqTransition the experiment from DRAFT to RUNNING:
curl -s -X PATCH http://localhost:3000/api/experiments/EXP_ID/status \
-H "Content-Type: application/json" \
-d '{"status": "RUNNING"}' | jq(Optional) You can manually publish a snapshot of the experiment config:
curl -s -X POST http://localhost:3000/api/experiments/EXP_ID/publish | jqStarting the experiment auto-publishes the latest config snapshot for the environment. The decision service uses the published config to deterministically assign variants:
curl -sG "http://localhost:3000/api/decide" \
--data-urlencode "user_key=user-123" \
--data-urlencode "env=prod" \
| jqResponse:
{
"user_key": "user-123",
"environment": "prod",
"config_version": 1,
"assignments": [
{
"experiment_key": "checkout-button-color",
"experiment_id": "...",
"variant_key": "control",
"variant_id": "...",
"payload": {"color": "#0066cc"}
}
]
}The same user_key always returns the same variant (deterministic hashing). Different user keys will be distributed across variants according to the allocation ranges.
You can add targeting rules to restrict which users are eligible for an experiment:
curl -s -X PATCH http://localhost:3000/api/experiments/EXP_ID \
-H "Content-Type: application/json" \
-d '{
"targetingRules": [
{
"conditions": [
{"attribute": "country", "operator": "in", "value": ["US", "CA"]},
{"attribute": "plan", "operator": "eq", "value": "pro"}
]
}
]
}' | jqThen pass context with the decide call:
curl -sG "http://localhost:3000/api/decide" \
--data-urlencode "user_key=user-123" \
--data-urlencode "env=prod" \
--data-urlencode 'context={"country":"US","plan":"pro"}' \
| jqexperiments/
βββ packages/shared/ # Shared types and hashing utility
βββ dashboard/ # Next.js UI for managing environments and experiments
βββ experiment-service/ # Control plane (Prisma + Postgres + S3)
βββ decision-service/ # Decision plane (in-memory config + S3 polling)
βββ api-gateway/ # Reverse proxy
βββ integration-tests/ # End-to-end and cross-service integration tests
βββ docker-compose.yml # Local infrastructure (Postgres, MinIO)
- User auth
- Rate limiting on /api/decide
- Observability & metrics
- Audience builder
- Event ingestion & conversion attribution
- Archive experiment clean-up
- React SDK
- LLM-assisted workflow

