DEV Community

Tummala Krishna Kishore
Tummala Krishna Kishore

Posted on Edited on

Central package management and feature flags solve the same problem at opposite speeds

I keep seeing the same argument in microservice estates, and it usually gets framed as a tooling choice. Should we pin every NuGet version in one place with central package management, or lean on feature flags so each service can ship new behaviour whenever it likes? Wrong framing. They don't compete. They control two different kinds of change, and most of the pain I see comes from using one to do the other's job.

Here is the split. Central package management, the Directory.Packages.props file with ManagePackageVersionsCentrally switched on, decides what code is compiled into your binaries. A feature flag decides which compiled code runs right now. One acts at build time, the other at runtime. Obvious when stated that way, and yet teams routinely try to roll out a risky library upgrade by hiding it behind a flag, which can't work.

Why not? Because the flag can't un-load an assembly. If service A upgrades a shared client library from 4.x to 5.x, the 5.x types are in the process whether the flag is on or off. Static initialisers run. Transitive dependencies get resolved. If 5.x drags in a newer System.Text.Json that changes a default, every serializer call in that service sees it, flag or no flag. You can gate the call site. You cannot gate the dependency graph.

So I'd say this plainly: a feature flag is not a safe way to adopt a package upgrade. It is a safe way to adopt a behaviour change that lives in code you own.

That sounds like a point for central package management, and for a single repo it mostly is. One file, one version of each package, a dependency bump is one diff that touches every project at once. Drift between projects disappears. Nobody ends up with three services on three patch levels of the same logging package, each failing in a subtly different way.

The cost is the blast radius. One edit to Directory.Packages.props changes the build of every service under that root. In a monorepo of, say, thirty services, a bump that breaks two of them blocks a merge that the other twenty-eight wanted. Central pinning trades per-service autonomy for consistency, and in microservices autonomy was the whole reason you split things up.

Which brings me to the part where I take a side. For services that deploy independently and are owned by different teams, I'd put the central file per team or per domain, not per repository tree. Shared across the things that genuinely upgrade together, separate across the things that don't. A single global props file for forty services is a monolith wearing a microservice costume. It just moved the coupling from the runtime to the restore step.

Feature flags have their own version of this trap. They are cheap to add and expensive to remove. Each flag doubles the paths through a function, and a service with ten live flags has, in principle, 1,024 configurations of which you tested maybe four. The flags that stay around past their rollout are the dangerous ones, because nobody remembers which combination production actually runs.

My rule of thumb: a flag gets an owner and an expiry date at creation, and the build warns when it's past due. A flag with no expiry is configuration pretending to be a release mechanism.

Now the interesting case. What about when you need both?

Say you're moving a service onto a new major version of a library and the new version changes behaviour you depend on. The sequence that has worked for me goes like this:

  1. Bump the package in the narrowest central file that covers the service. Nothing else changes yet.
  2. Keep the old code path and add the new one beside it, selected by a flag defaulting to old.
  3. Ship. At this point the new library is loaded but the behaviour is unchanged. That is the entire point of splitting the step in two.
  4. Flip the flag for a slice of traffic, watch, widen.
  5. Delete the flag and the old path in the next release.

Notice that step 1 and step 4 are different risks on different timelines. If something goes wrong at step 3, a rollback is a redeploy. If something goes wrong at step 4, a rollback is a config change taking seconds. Mixing them into one release means a bad outcome can't be attributed to either, and you end up bisecting in production.

This only works when the library lets old and new behaviour coexist in one process. When it doesn't, say a wire-format change on a shared contract package, no flag will save you. That needs a compatibility window: publish a version that reads both formats, upgrade every consumer, and only then start writing the new one. Slower, yes. Still the only honest option.

Shared contract packages are where central management earns its keep in a way people undervalue. If services exchange messages through a common contracts library, then the version of that library is effectively part of your API. Letting each service float on its own version there is how you get a producer writing a field the consumer's older copy silently drops. Pin those centrally and bump them deliberately. Let leaf libraries, the ones that touch only internal code, drift per service if teams want.

One more thing I'd push back on. People say flags give you "independent deployability." They give you independent activation. Deployability is about the build and the artifact, and it's dependency versions that tie services together there. A fleet where every service is flag-driven but all share one pinned dependency set still has to move in lockstep for anything that matters.

So the decision isn't flags versus central management. It's two questions you ask separately. Does this change alter what gets compiled in? Then it's a package decision, scoped as narrowly as your ownership boundaries allow. Does it alter what runs? Then it's a flag, with an owner and a date.

Get those the right way round and the tradeoff mostly dissolves. Get them swapped and you'll be debugging a "flagged-off" feature that is still breaking production, because the thing that broke was never behind the flag.

Top comments (0)