A production-grade, self-hosted, A/B testing & feature flag framework for Node.js
Deterministic bucketing · Mongoose-backed storage · Layered mutual exclusion · Drop-in Express routes · React admin UI
Quickstart · Features · Docs · Admin UI · Examples · Seed data · Tests · Contributing
Mendel Framework is a batteries-included experimentation and feature-flagging platform you embed in your Node.js application. It is purpose-built for teams that want GrowthBook / LaunchDarkly-style capabilities without the SaaS dependency or vendor lock-in; everything runs on your own Mongo cluster, behind your own auth, in your own VPC.
Named after Gregor Mendel; the father of modern genetics, who pioneered the concept of controlled experiments on inherited traits; the framework brings the same rigor to product experimentation: deterministic, reproducible, statistically sound.
The framework is completely generic. It carries no business concepts. Audience selection is expressed as targeting rules over an attribute bag the caller supplies at evaluation time. Bucketing is deterministic, so the same (experiment, item) pair always resolves to the same variant — across servers, across regions, across SDKs.
Read to know How A Weekend Hack Became Our Feature Experimentation Backbone
- 🎯 Deterministic bucketing: FNV-1a hashing keeps
(salt, item_id)→ variant assignments stable across servers and over time. No coordination required between the server and a client SDK. - 🚦 Two rollout modes:
A_B_TESTINGfor probabilistic bucketing into weighted variants, andFEATURE_FLAGfor explicit per-item enrollment. - 🎚 Rich targeting rules:
eq,in,gt(e),lt(e),contains,starts_with,ends_with,regex,existsand more, combined withall/anysemantics. - 🧬 Layers & holdouts: Group experiments into a layer to enforce mutual exclusion, with optional global holdouts for measuring cumulative lift.
- 🔗 Prerequisites: Gate one experiment on another experiment's variant assignment.
- 🛡 Force-assign & QA overrides: Pin specific items to specific variants for QA, demos, or customer escalations.
- 📦 Variant payloads: Ship arbitrary JSON payloads alongside each variant (copy, config, feature toggles).
- ⚡ Built-in TTL cache: Hot-path
getConfigDatareads are cached in-process with automatic invalidation on writes. - 🌐 Express integration: Drop-in client and admin route mounting with
celebratevalidation (optional dependency). - 🖥 React admin UI: Manage experiments, layers, items, and overrides through a fully functional dashboard.
- 🐳 Docker-ready:
docker compose upto bring up Mongo + backend + UI in seconds.
The bundled admin UI gives non-engineers a safe surface for managing experiments end-to-end.
Running / graduated / failed counts, a breakdown of A/B tests vs. feature flags vs. active layers, and a quick-jump table of running experiments.
Filter by name, state, status, type, or environment. Rollout, variant count, layer membership and active-state are all visible at a glance.
A single page covers everything about an experiment: status, rollout, variant weights & payloads, targeting rules, layer assignment, and the live list of enrolled items — including force-assign and per-item variant overrides.
A guided form for setting up new experiments — rollout type, salt, dates, variants with JSON payloads, targeting rules, prerequisites, and layer assignment.
![]() |
![]() |
Group experiments into a layer to enforce mutual exclusion across audiences. Carve out a global holdout slice so you can measure cumulative lift from everything in the layer.
npm install mendel-framework mongoose
# Optional — only required if you mount the Express admin routes.
npm install celebrateRequires Node.js ≥ 22 and a reachable MongoDB instance.
const mongoose = require('mongoose');
const { v4: uuid } = require('uuid');
const { createMendelFramework, ROLL_OUT_TYPE, TARGETING_OP } = require('mendel-framework');
await mongoose.connect(process.env.MONGO_URI);
const { service, manager } = createMendelFramework(mongoose, {
generateId : uuid,
environment : 'prod',
cache : { enabled: true, ttlMs: 5000, max: 1000 },
persistAudit : true,
persistExposure : false,
onAuditEvent : (event, payload) => console.log(`[audit] ${event}`, payload),
onExposure : (e) => console.log(`[exposure] ${e.exp_name} → ${e.variant_key}`),
});await service.createExperiment({
exp_name : 'exp_new_checkout',
hypothesis : 'Streamlined checkout improves conversion for enterprise customers.',
exp_type : 'flag',
roll_out_type : ROLL_OUT_TYPE.A_B_TESTING,
roll_out_value: 80, // 80% of eligible traffic participates
variants: [
{ key: 'control', weight: 50, payload: { ui: 'classic' } },
{ key: 'treatment', weight: 50, payload: { ui: 'streamlined' } },
],
targeting: {
match: 'all',
rules: [
{ attribute: 'plan', op: TARGETING_OP.IN, values: ['pro', 'enterprise'] },
{ attribute: 'country', op: TARGETING_OP.EQ, values: 'US' },
],
},
start_date: Date.now(),
end_date : Date.now() + 30 * 24 * 60 * 60 * 1000,
}, { id: 'admin' });const attributes = { plan: 'enterprise', country: 'US', tier: 3 };
// Full evaluation — variant + reason + payload
const result = await service.evaluate('exp_new_checkout', 'USER_42', attributes);
// → { variant: 'treatment', reason: 'bucketed', exp_id: '…', payload: { ui: 'streamlined' } }
// Convenience helpers
const variant = await service.getVariant('exp_new_checkout', 'USER_42', attributes);
const isEnabled = await service.isEnabled('exp_new_checkout', 'USER_42', attributes);
// Batch lookup for an SDK / mobile client
const config = await manager.getConfigData(['USER_42', 'ORG_7'], attributes);const express = require('express');
const {
express: { ExperimentController, mountRoutes, mountAdminRoutes },
} = require('mendel-framework');
const app = express();
app.use(express.json());
const controller = new ExperimentController({
experimentService : service,
experimentManager : manager,
});
const clientRouter = express.Router();
mountRoutes(clientRouter, controller);
app.use('/api/v1', clientRouter);
const adminRouter = express.Router();
mountAdminRoutes(adminRouter, controller, {
authMiddleware: yourAuthMiddleware,
});
app.use('/api/admin', adminRouter);See examples/integration.js for the full annotated walk-through.
docker compose up --buildBrings up:
| Service | URL |
|---|---|
| UI | http://localhost:3100/ |
| Backend | http://localhost:3000/ (proxied through the UI) |
| Mongo | mongodb://localhost:27017/mendel-framework |
Tear down with docker compose down (preserves data) or docker compose down -v (drops the volume).
For local dev, a seed script ships with the framework that populates Mongo with a representative set of records so the admin UI and the client APIs have something to render right away.
# Defaults to mongodb://127.0.0.1:27017/mendel-framework
npm run seed
# Or point it at a different database
MONGO_URI=mongodb://127.0.0.1:27017/mendel-dev npm run seedThe seeder is idempotent — it deletes the previously seeded records (matched by name) before re-inserting them, so you can re-run it freely.
What it creates:
| Kind | Examples |
|---|---|
| Layers | checkout_layer (10% holdout), growth_layer (5% holdout, default) |
| A/B tests | exp_new_checkout, exp_new_billing (layered & mutually exclusive) |
| Feature flag | flag_dark_mode (explicit per-item enrollment) |
| Banner | banner_summer_sale (targets US / CA / GB) |
| Prerequisite | exp_checkout_upsell (only runs for exp_new_billing#treatment) |
| Graduated | exp_search_relevance (SUCCESS_STATUS.SUCCESS — serves treatment to everyone) |
| Retired | exp_retired_homepage (SUCCESS_STATUS.FAILURE, inactive) |
| Forced items | USER_1..3 pinned to exp_new_checkout#treatment; USER_42, USER_7 to flag_dark_mode#on |
See examples/seed.js for the full payload — copy and tweak it as a starting point for your own fixtures.
Mendel Framework uses Node.js's built-in test runner (node:test), so the suite runs with zero extra dependencies and no test framework setup.
npm testTests are organised by module and live under test/:
| File | What it covers |
|---|---|
test/bucketing.test.js |
FNV-1a determinism, hashToUnit range, pickVariant weight distribution at N=20k, inRollout / inHoldout independence |
test/targeting.test.js |
Every operator (eq/neq/in/nin/numeric/string/regex/exists), invalid-regex safety, all vs any semantics |
test/cache.test.js |
TTL expiry, capacity-based eviction, cacheKey stability across input ordering & nested objects |
test/service.test.js |
ExperimentService._evaluate decision tree across every EXPOSURE_REASON (NOT_FOUND, ENVIRONMENT_MISS, INACTIVE, NOT_STARTED/ENDED, GRADUATED, TARGETING_MISS, BUCKETED, ROLLOUT_MISS, FORCED, ENROLLED, HOLDOUT), plus audit-hook smoke test |
The service tests use thin in-memory model stubs so the suite runs in well under a second and does not require MongoDB. If you add behaviour that talks to Mongo (e.g. new aggregation pipelines), add an integration test against a real or in-memory Mongo instance — the existing suite intentionally stays at the unit-test layer.
One-shot factory that wires up models, the ExperimentService (mutations + evaluation), and the ExperimentManager (cached batch reads).
| Option | Type | Default | Notes |
|---|---|---|---|
generateId |
Function |
— | Required. Used to mint document _ids (e.g. uuid.v4). |
environment |
string |
'prod' |
Scopes evaluation to experiments tagged with the same environment. |
cache |
object |
— | { enabled, ttlMs, max } — in-process TTL cache for getConfigData. |
persistAudit |
boolean |
false |
Mirror audit events to ExperimentAudit collection. |
persistExposure |
boolean |
false |
Mirror every evaluation to ExperimentExposure collection. |
onAuditEvent |
Function |
noop | (event, payload) => void — stream to your analytics pipeline. |
onExposure |
Function |
noop | (event) => void — typically forwarded to Segment / Amplitude / BQ. |
onItemsChanged |
Function |
noop | (expName, item, action) => void — side-effects on enrollment. |
service.createExperiment(data, auditUser)service.updateExperiment(expId, data, auditUser)service.cloneExperiment(expId, overrides, auditUser)service.addItems(expId, itemIds, auditUser, opts)/addItemsBulkservice.removeItem(expId, itemId, auditUser)service.forceAssign(expName, itemId, variantKey, auditUser)service.evaluate(expName, itemId, attributes, opts)— returns{ variant, reason, payload, exp_id }service.getVariant(expName, itemId, attributes)service.isEnabled(expName, itemIdOrIds, attributes)service.assignVariant(expName, itemId, attributes)— probabilistic enrollment + persistservice.createLayer(data, auditUser)/assignToLayer(layerId, expIds, auditUser)manager.getConfigData(itemIds, attributes)— batched lookup with cache
const {
EXP_TYPE, // banner | flag | general
ROLL_OUT_TYPE, // A_B_TESTING | FEATURE_FLAG
SUCCESS_STATUS, // RUNNING | SUCCESS | FAILURE
EXPOSURE_REASON, // not_found | inactive | bucketed | enrolled | targeting_miss | …
TARGETING_OP, // eq | in | gt | contains | regex | …
TARGETING_MATCH, // all | any
} = require('mendel-framework');| Method | Path | Purpose |
|---|---|---|
GET |
/api/v1/config-data |
Batch evaluation for a list of item IDs |
POST |
/api/v1/evaluate |
Evaluate a single (exp_name, item_id) pair |
POST |
/api/v1/assign-variant |
Probabilistic enrollment + persist |
GET |
/api/admin/experiments |
List / search experiments |
POST |
/api/admin/experiment/setup |
Create experiment |
POST |
/api/admin/experiment/:id |
Update experiment |
POST |
/api/admin/experiment/:id/clone |
Clone experiment |
POST |
/api/admin/experiment/add-items/:id |
Add items to experiment |
POST |
/api/admin/experiment/force-assign |
Pin item → variant |
GET |
/api/admin/layer / /layer/:id |
List / get layers |
POST |
/api/admin/layer |
Create layer |
Every variant assignment in Mendel Framework is stateless and deterministic:
bucket = FNV-1a(`${salt}:${item_id}`) / 2^32 ∈ [0, 1)
saltdefaults to the experiment name, but can be customized so two experiments with overlapping audiences don't share bucket space.- The rollout check uses a separate suffix (
${salt}:rollout) so an item's eligibility decision is independent of which variant it lands in once eligible. - Holdout selection uses
${layerSalt}:holdout, again independent from variant bucketing.
This means a client SDK with the same hashing logic can evaluate flags identically to the server without round-tripping — perfect for mobile, edge, or SSR.
We love contributions of all sizes — bug reports, feature ideas, docs improvements, and pull requests are all welcome.
Read CONTRIBUTING.md for development setup, branching conventions, commit style, and the PR checklist.
Mendel Framework is released under the MIT License.
If Mendel Framework helps your team ship safer experiments, please ⭐ the repo — it really does help.





