What feature flags in a monolith are and why they fit trunk based development

A feature flag is a runtime switch that decides which code path runs. The new code is already in the running binary, and the flag decides whether anyone reaches it. That separates two events teams often treat as one: deploying code (putting it on the servers) and releasing behavior (letting users or callers hit it). With a flag, you can deploy on Tuesday and release on Thursday, or release to one tenant and leave everyone else alone.

This fits a monolith well because a monolith already has a single deployable artifact and usually a shared release train. Trunk based development asks everyone to merge to the main branch often, at least daily, instead of keeping long-lived branches. The obvious objection is that half-finished work would ship. A flag that is off by default answers it. The incomplete code merges, compiles, passes tests and deploys, but stays dormant. You avoid painful merges of a branch that drifted for weeks, and the team keeps one artifact instead of maintaining variants.

The other benefit is reversal. If a change misbehaves in production, turning the flag off restores the old path without a rebuild or redeploy. In a large monolith, a redeploy can take a long time and may carry other teams' changes with it.

Teams that share one codebase and one release cadence benefit most, because one team's unfinished work can't block or endanger another's release. Reach for a flag when a change is risky, when work will span many merges, or when you need to be able to undo it quickly.

Picking the seam: one flag check at a module boundary

The most reliable rule is to evaluate each flag exactly once, at a boundary where the old and new behaviour can be swapped as whole units. That boundary might be a service interface such as PricingService, a dependency-injection binding, or a strategy object the caller already receives. The calling code never asks whether the flag is on. It calls pricing.quote(order) and gets whichever implementation was wired in.

In practice, LegacyPricingService and TieredPricingService both implement the same interface. A single factory or DI binding owned by the pricing module reads pricing.tiered-engine and returns one or the other. Two things follow from this design. The new path can be built and tested in isolation. Deleting the flag later means removing one branch and one class, not searching the codebase for if statements.

The module that owns the seam should own the flag. It defines the flag, documents what turning it off does, and is responsible for removing it. This keeps the flag's meaning tied to the code it changes. It also makes the person to page for a rollout obvious.

The failure mode is a flag read deep inside another team's module. For example, the checkout code might branch on pricing.tiered-engine to adjust a rounding rule. Checkout now depends on pricing's internal rollout state without any interface showing that dependency. Pricing can't safely remove or repurpose the flag. Nobody can say who decides when it goes to 100%. If checkout genuinely needs different behaviour, expose that difference through pricing's interface.

// pricing/PricingService.ts: the seam. Callers depend only on this interface.
export interface PricingService {
  quote(order: Order): Quote;
}

class LegacyPricingService implements PricingService {
  quote(order: Order): Quote { /* existing flat-rate logic */ }
}

class TieredPricingService implements PricingService {
  quote(order: Order): Quote { /* new tiered engine, tested in isolation */ }
}

// Owned by the pricing module. This is the ONLY place the flag is read.
// Flag: pricing.tiered-engine
// Off = LegacyPricingService (flat rates). Owner: pricing team.
// Removal: delete this branch and LegacyPricingService.
export function createPricingService(flags: FlagClient): PricingService {
  return flags.isEnabled("pricing.tiered-engine")
    ? new TieredPricingService()
    : new LegacyPricingService();
}

// checkout/CheckoutController.ts: no flag check, just the interface.
class CheckoutController {
  constructor(private pricing: PricingService) {}

  submit(order: Order) {
    const quote = this.pricing.quote(order);
    // Avoid: if (flags.isEnabled("pricing.tiered-engine")) { adjustRounding() }
    // That would couple checkout to pricing's rollout state. If rounding
    // must differ, return it from Quote instead.
    return this.charge(order, quote);
  }
}

A pricing-owned factory reads the flag once and binds one implementation behind PricingService, so checkout calls the interface and never sees the flag.

Flag types and how each one's lifetime differs

Not every flag is a release flag, and treating them all alike is how a codebase ends up with hundreds of checks nobody dares delete. The type of a flag decides who owns it, what "off" should mean, and when it has to go.

Type Safe default Owner Expected lifetime Removal rule
Release flag Off (old path) Team shipping the change Days to weeks Delete once at 100% and stable for one release cycle
Operational kill switch On (feature enabled), with a tested off state Service or on-call owner Indefinite, by design Remove only when the protected dependency or feature is gone
Experiment flag Control variant Product owner plus the team analysing results Length of the experiment Delete when the decision is recorded, losing variant included
Permission / entitlement flag Deny Product or billing As long as the plan or role exists Migrate to the authorization model if it outgrows a flag

Release flags are the ones to watch. They are meant to die, so give each one an owner and an expiry date when you create it. Entitlement flags are business logic that happen to use flag infrastructure. Keep them out of your release-flag cleanup reports, or the reports turn into noise.

Kill switches need different care. A switch that nobody has flipped in a year guards a code path that nobody has run in a year. The fallback may call a removed method, skip a migration that has since landed, or overload a cache that grew used to serving warm traffic. Exercise the off state on a schedule: in staging at minimum, and in production during a quiet window if you can. Do this so the first real use is not also the first test.

Progressive rollout, one module at a time

Advance each flag through the same stages in order. Enable it in a non-production environment, then for internal users, then for an allowlist of tenants or accounts that agreed to early access, and finally for a percentage of all traffic. Each stage widens exposure only after the previous one has produced evidence.

At the percentage stage, bucket deterministically. Hash the flag name together with a stable key such as user or tenant ID, take the result modulo 100, and compare it with the rollout percentage. The same user then gets the same path on every request, so sessions and caches don't flip between implementations. Including the flag name in the hash keeps a single cohort from being first in line for every flag.

Order modules by blast radius and reversibility. Put read-only paths before writes, and internal reports before checkout. Before each step, write down what "healthy" means: an error rate, a p99 latency, and a business invariant such as "invoice totals equal the sum of line items." Tag every metric with the variant served, so the old and new paths are judged on identical measurements.

Evaluate all flags once at the request edge and carry the results through the request:

request (user_id=u42)
   │
   ▼
evaluate flags once ──► { search.v2: off, billing.v2: on }
   │                     (stored in request context and logs)
   ▼
[auth] ──► [search] ──────────► [billing] ──────────► response
              │                     │
          old path              new path
              ▼                     ▼
   metrics{search,variant=off}  metrics{billing,variant=on}

Two things weaken the signal. A low-traffic module may need days at a small percentage before a regression stands out from noise. A request that crosses several flagged modules also makes it unclear which flag caused a regression. Log the full set of evaluated variants per request, and avoid ramping two flags on the same request path at the same time.

import hashlib

# Rollout state per flag. Stages are checked in order: environment,
# internal users, tenant allowlist, then percentage of all traffic.
FLAGS = {
    "billing.v2": {"envs": {"staging", "prod"}, "internal": True,
                   "tenants": {"acme-corp"}, "percent": 10},
    "search.v2": {"envs": {"staging"}, "internal": True,
                  "tenants": set(), "percent": 0},
}

def bucket(flag: str, key: str) -> int:
    # Hash flag name + stable key so the same user always lands in the same
    # bucket for this flag, but cohorts differ across flags.
    digest = hashlib.sha256(f"{flag}:{key}".encode()).digest()
    return int.from_bytes(digest[:8], "big") % 100

def is_on(flag: str, env: str, user_id: str, tenant_id: str, is_internal: bool) -> bool:
    cfg = FLAGS[flag]
    if env not in cfg["envs"]:
        return False
    if is_internal and cfg["internal"]:
        return True
    if tenant_id in cfg["tenants"]:
        return True
    return bucket(flag, user_id) < cfg["percent"]

def evaluate_at_edge(env: str, user_id: str, tenant_id: str, is_internal: bool) -> dict:
    # Evaluate every flag once per request; modules read this dict from the
    # request context instead of re-evaluating, and metrics/logs are tagged
    # with the variant so old and new paths share identical measurements.
    return {f: ("on" if is_on(f, env, user_id, tenant_id, is_internal) else "off")
            for f in FLAGS}

if __name__ == "__main__":
    ctx = evaluate_at_edge("prod", user_id="u42", tenant_id="globex", is_internal=False)
    print(ctx)  # same output on every run for u42: assignment is deterministic

Staged flag evaluation in Python: environment, internal users, tenant allowlist, then deterministic percentage bucketing on a hash of the flag name and user ID, computed once at the request edge.

Shared state, data changes and the limits of a flag rollback

Turning a flag off changes which code runs on the next request. It does not reverse anything the new path already did. Rows written in a new format stay in that format. A migration that dropped a column has already dropped it. An email sent or a payment captured through a third-party API cannot be recalled. If the old code path cannot read what the new path wrote, the "rollback" becomes a second incident.

Treat data changes as their own staged rollout. Separate them from the behavioural flag, and finish them before the flag ramps.

  • Expand/contract: first add the new column or table without removing anything. Old and new code then both work against the same schema. Contract, meaning drop the old structure, only after the flag is removed.
  • Dual writes: while both paths are live, write to the old and new representations. The old path then keeps a complete view if you flip back. Run a reconciliation job to compare the two before you trust the new one.
  • External calls: make them idempotent, or put them behind their own flag that you ramp last.

Some changes cannot be made backward compatible. Others are too large for a per-request branch. For those, a flag is the wrong tool:

Situation Better fit Why
Long internal refactor with no user-visible change Branch by abstraction, no runtime toggle Swap implementations behind an interface and ship each step. No flag state to manage.
Change to dependencies, runtime or startup config Separate deploy or canary release One process cannot hold both variants.
Module that needs its own scaling, data store or release cadence Extract into a service The flag would only hide a boundary that already exists.

Testing and evaluating flags inside a single process

Ten independent boolean flags give 1,024 possible states, and no suite should try to cover them all. Most flags never interact: a flag guarding the billing module and one guarding search ranking touch disjoint code. Test the combinations where flags share a code path or data, plus two baselines: everything at its production default, and the new paths you are actively rolling out. Every other pair is a cost with no matching risk.

The off path matters as much as the on path. It is the rollback route, and it decays without anyone noticing. If CI only exercises the new branch, the first person to learn that the old one broke is the engineer flipping the flag during an incident. Run the default-state suite on every commit, and pin flag values explicitly in each test so a change in a shared config file cannot change test results.

Evaluate once, pass the decision down

Read every flag the request needs at the edge and store the results in a request-scoped context object. Evaluating at each call site lets a mid-request config change send one request down both paths. That produces exactly the mixed state your rollback plan assumes cannot happen.

Cache the flag configuration in process memory and refresh it in the background. If the flag store is unreachable, use the last known value, or a hard-coded safe default (usually off) if no value has been loaded yet. Attach the resolved flag set to the request's logs or trace. During an incident, "which variant did this request see?" should take one query to answer, not guesswork.

import logging
from types import MappingProxyType

import pytest

log = logging.getLogger("flags")
SAFE_DEFAULTS = {"billing_v2": False, "search_rerank": False}  # used until config loads

class FlagCache:
    """In-process copy of flag config. A background thread calls refresh()."""
    def __init__(self, store):
        self._store, self._last_known = store, {}  # store: your flag backend client
    def refresh(self):
        try:
            self._last_known = dict(self._store.fetch_all())
        except ConnectionError:
            log.warning("flag store unreachable; keeping last known values")
    def resolve(self, name):
        return self._last_known.get(name, SAFE_DEFAULTS[name])

class FlagContext:
    """Flags resolved once per request, then passed down read-only."""
    def __init__(self, values):
        self.values = MappingProxyType(dict(values))
    @classmethod
    def for_request(cls, cache, request_id):
        ctx = cls({name: cache.resolve(name) for name in SAFE_DEFAULTS})
        # One log line per request answers "which variant did this request see?"
        log.info("flags resolved", extra={"request_id": request_id, "flags": dict(ctx.values)})
        return ctx
    def is_on(self, name):
        return self.values[name]

@pytest.fixture
def pinned_flags():
    # Every flag gets an explicit value; shared config files cannot leak in.
    return lambda **overrides: FlagContext({**SAFE_DEFAULTS, **overrides})

def test_default_state_keeps_old_billing_path(pinned_flags):
    assert pinned_flags().is_on("billing_v2") is False

A request-scoped FlagContext built once from a locally cached config that keeps last known values or safe defaults when the flag store is unreachable, plus a pytest fixture that pins every flag so tests stay deterministic.

Flag cleanup, cost and when to avoid flags

Removal belongs to the same piece of work as creation. When someone adds a release flag, give it a named owner and an expiry date, and open the removal ticket in the same change. Add a CI or lint step that reads the flag definitions and reports any flag past its expiry. Cleanup means deleting the losing code path, the flag check, its tests and the flag definition. If you delete only the definition, the code silently falls back to its default.

Every flag adds cost. It doubles the paths through the code it guards, it makes each review touching that code harder, and it adds a runtime dependency on flag evaluation. A flag that is never removed becomes configuration debt. Months later nobody knows whether the "off" path still works, so deleting it feels riskier than leaving it.

The savings come from fewer long-lived branches and merge conflicts, from rollbacks that don't need a redeploy, and from incidents limited to one module.

Illustrative estimate (assumed numbers, not a benchmark): net hours = (conflict hours avoided + rollbacks × responders × redeploy hours) − (add + review + removal hours)

Assume 4 h of conflict resolution avoided and one rollback by 3 responders that saves 0.75 h of redeploy each. That gives 4 + 2.25 = 6.25 h saved. Against that, assume 1 h to add the flag, 1 h of extra review and 2 h to remove it, for 4 h of cost. The net is about 2 h. If removal slips, the cost side keeps growing and the net shrinks.

Skip flags in three cases:

  • Trivial changes, where the flag costs more than the change.
  • Mostly-data migrations, which a flag cannot undo.
  • Teams without the review discipline to hold "off anyway" code to the same standard as live code.
#!/usr/bin/env python3
"""CI check: fail the build when a release flag is past expiry or lacks an owner."""
import json
import sys
from datetime import date

# Example entry in flags.json, written in the same change that adds the flag:
# {"name": "billing.new_invoice_pdf", "type": "release", "owner": "team-billing",
#  "expires": "2026-12-01", "removal_ticket": "BILL-482"}
FLAGS_FILE = sys.argv[1] if len(sys.argv) > 1 else "flags.json"
REQUIRED = ("name", "owner", "expires", "removal_ticket")


def main() -> int:
    with open(FLAGS_FILE, encoding="utf-8") as f:
        flags = json.load(f)
    today = date.today()
    problems = []
    for flag in flags:
        if flag.get("type") != "release":
            continue  # ops kill switches are long-lived by design; only release flags expire
        missing = [k for k in REQUIRED if not flag.get(k)]
        if missing:
            problems.append(f"{flag.get('name', '?')}: missing {', '.join(missing)}")
            continue
        expires = date.fromisoformat(flag["expires"])
        if expires < today:
            # Point at the owner and ticket so the report leads straight to cleanup work
            problems.append(f"{flag['name']}: expired {expires}, owner {flag['owner']}, "
                            f"ticket {flag['removal_ticket']}")
    for p in problems:
        print(f"FLAG CHECK: {p}")
    # Non-zero exit fails the CI job; switch to 'return 0' to report without blocking
    return 1 if problems else 0


if __name__ == "__main__":
    sys.exit(main())

A standard-library Python CI step that reads the flag definitions and fails the build when a release flag has no owner, expiry date or removal ticket, or has passed its expiry date.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to feature flags monolith, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.