Prompt
What’s the best way to add feature flags to an existing monolith without making the code messy?
Latest observation
The cleanest way to add feature flags to an existing monolith is to treat them like a small, well-contained infrastructure concern, not something you sprinkle throughout business logic.
Here’s a practical approach that keeps the codebase from turning into if (flag) ... else ... soup.
1) Start with a clear flag policy
Before adding code, decide what kinds of flags you’ll allow:
- Release flags: temporary, used to safely ship incomplete work
- Ops flags: for kill switches / emergency disablement
- Experiment flags: A/B or targeted rollout
- Permission flags: long-lived access control
- Don’t use flags for permanent branching when a real design split would be cleaner
A lot of mess comes from not distinguishing “temporary rollout control” from “product logic.”
2) Centralize flag access
Avoid direct reads from a vendor SDK all over the codebase.
Instead:
- Create a small internal
FeatureFlagServiceorFlagsinterface - Put all vendor-specific logic behind it
- Let the rest of the app depend on your wrapper
Example idea:
if flags.is_enabled("new_checkout", user=user):
...
Not:
if vendor_client.get("new_checkout", user.id):
...
This makes it easier to:
- swap providers
- mock flags in tests
- enforce naming and defaults
- log usage
3) Keep flag checks near the boundaries
The mess usually happens when flag checks are buried deep in core domain logic.
Prefer:
- controllers / handlers
- service composition layer
- routing / orchestration code
Avoid scattering checks across:
- entities
- utility functions
- low-level helper methods
A good pattern is to decide the variant early, then pass the chosen strategy/service down.
Example:
handler = NewCheckoutHandler() if flags.is_enabled("new_checkout", user) else LegacyCheckoutHandler()
handler.process(order)
This keeps the conditional in one place.
4) Use strategy or adapter patterns for big branches
If a flag affects substantial behavior, don’t wrap every line with conditionals.
Instead:
- extract the new and old behavior into separate classes/functions
- choose between them once
This is especially useful when:
- there are many related differences
- the branch will exist for more than a short time
- you want to remove the old path later
5) Make flags explicitly temporary
Every flag should have:
- an owner
- a purpose
- an expiration/removal date
- a cleanup plan
Track them in code comments, config, or a flag registry.
Example metadata:
new_checkout- owner:
team-payments - expires:
2026-01-15 - type:
release
This prevents permanent “temporary” code.
6) Add guardrails in code review and tooling
You can keep things tidy with conventions:
- require a ticket or doc for each new flag
- forbid nested flag checks beyond a certain depth
- warn on flags older than X days
- lint for direct SDK usage outside the wrapper
- maintain a list of stale flags
If possible, automate flag cleanup reminders.
7) Test both paths, but don’t explode test volume
For important flags:
- write tests for enabled and disabled behavior
- test the branching logic at the orchestration level
- avoid duplicating all downstream tests for both variants
A useful approach:
- unit test the variant selection
- unit/integration test each implementation separately
- add one or two end-to-end tests for the whole flow
8) Prefer config-driven rollout over code-driven branching when possible
Sometimes the cleanest flag isn’t a code branch at all.
For example:
- route a percentage of traffic to a new service
- enable a new handler via config
- choose implementation through dependency injection
If the difference is mostly operational, configuration can be cleaner than conditionals.
9) Remove flags aggressively
The best way to avoid mess is to delete flags once they’ve served their purpose.
When the flag is fully rolled out:
- remove the old path
- simplify tests
- delete the flag check
- delete the flag from the registry/provider
Stale flags are one of the biggest sources of monolith complexity.
A good rule of thumb
If a flag check would appear in more than a couple places, stop and ask:
- Can I make this a single decision point?
- Can I split the implementations?
- Can I push the branching to composition/config instead of core logic?
Recommended architecture
A simple, maintainable pattern is:
- Flag wrapper/service
- Single decision point
- Strategy/adapter for large branches
- Flag metadata + cleanup process
- Tests for both paths
- Aggressive removal after rollout
If you want, I can also show:
- a bad vs good example in code,
- a flag service interface for a specific language, or
- a migration plan for introducing flags into an existing monolith gradually.