Uncategorized

At 30 engineers, CI and code ownership fail before runtime

In a 15-30 person engineering group, the first thing to break in an inherited, untested legacy system is rarely the build; it is the team’s ability to know which version of production exists. My position is unfashionable: scaling safety here starts with flag governance and deletion pressure, not with a heroic test-suite rebuild, because uncontrolled variants hide defects faster than tests can reveal them.

Flag ownership breaks before test coverage does

Inherited an untested codebase What QA advice is obsolete now treats obsolete QA advice as a testing problem, but I think the scaling failure is ownership because a test can fail loudly while an ownerless production path can stay half-enabled for months.

The first visible symptom is usually not a red GitHub Actions workflow or a missing JUnit 5.10 assertion. It is a question nobody can answer in under two minutes: “Is the old checkout still live for enterprise customers?” That question sounds operational, but it is really an architecture smell because the system now has multiple behavioral contracts and no single source of truth for them.

In a small team, tribal memory can carry this. At 6 engineers, someone remembers that the “temporary” branch was added during a pricing incident. At 18 engineers, a hand-counted inventory of 41 active flags is already a warning sign because every new joiner has to learn product history before they can safely change code. At 30 engineers, even diligent people stop asking because the social cost of interrupting three Slack channels exceeds the perceived risk of shipping.

The disagreeable part: I would rather have 55% meaningful branch coverage around owned seams than 85% broad coverage around unknown behavior, because legacy tests that encode accidental behavior make deletion politically harder. JaCoCo 0.8.12, Istanbul/nyc 15.1, and coverage.py 7.x are useful instruments, but the percentage is secondary because coverage does not tell you whether production still depends on the branch being tested.

Put every release switch under the same governance shape before arguing about the perfect framework. A practical minimum is:

  • Owner: a team or named maintainer, enforced through CODEOWNERS rather than a wiki because review routing must happen at pull request time.
  • Expiry: a date, not a vague cleanup promise, because old flags become invisible once the release succeeds.
  • Blast radius: percentage rollout, tenant list, or request cohort, because “enabled” is too crude for debugging mixed production states.
  • Observable outcome: an SLO, Prometheus counter, OpenTelemetry span attribute, or Datadog monitor, because rollback without a signal is guesswork.

Use 21 days as a starting value to tune for ordinary release flags, not as doctrine, because most product toggles that survive three weekly planning cycles have turned into either configuration or dead code. Here is a tiny check that runs with the Python standard library and fails CI when a flag is expired or ownerless:

cat > flags.json <<'JSON'
{"checkout_v2":{"owner":"payments","expires":"2026-10-01"},
 "old_tax_flow":{"owner":"","expires":"2024-03-01"}}
JSON
python3 - <<'PY'
import json, sys
from datetime import date
flags = json.load(open("flags.json"))
bad = []
for name, meta in flags.items():
    exp = date.fromisoformat(meta["expires"])
    if not meta.get("owner") or exp < date.today():
        bad.append(name)
print("bad flags:", ", ".join(bad))
sys.exit(1 if bad else 0)
PY

This check is deliberately boring because boring enforcement survives turnover. A custom dashboard may look better, but CI is where developers already negotiate risk.

Variant explosion hurts before the test pyramid can save you

Add feature flags to legacy code without rewriting everything is right to avoid a rewrite, yet feature flags become debt fastest because every flag doubles a path in theory and creates just enough ambiguity in practice to make incidents slow.

You do not actually test every combination, and pretending otherwise is how teams end up with a ceremonial test matrix. Ten Boolean flags imply 1,024 theoretical combinations; that figure is just arithmetic, but it matters because a 20-person team cannot reason about that space during an incident. The failure mode is selective blindness: Playwright 1.45 end-to-end tests cover the happy path with defaults, pytest 8 covers helper functions, and Pact 4 contract tests cover one provider state, while the broken production path is “flag A on, flag C off, account migrated before March.”

That does not mean flags are bad. It means flags must be classified. Release flags should be short-lived. Permission flags should be durable and modeled. Experiment flags should be owned by the team that reads the result. Ops kill switches should be tested under failure. A single “feature_flag” abstraction is too vague because the lifecycle, observability, and deletion rule differ.

The metrics that scale are the ones that reduce the search space. I like these because each forces a decision:

  • Flag age: a tunable limit such as 21 or 30 days for release flags, because age predicts forgotten context better than code size.
  • Active production variants: a measured count from your flag provider or config store, because incident responders need to know how many realities exist.
  • p95 request latency by variant: a service-level slice such as “checkout_v2=true” in Prometheus or Grafana, because average latency hides cohort-specific regressions.
  • Error budget burn by cohort: an SRE-style measure tied to the SLO, because a safe rollout is safe only for the users receiving it.
  • Change failure rate: one of the four DORA metrics, because scaling a legacy system is mostly about reducing the cost of being wrong.

A vendor-documented number worth remembering: Playwright’s default test timeout is 30,000 ms, which is generous enough to mask slow legacy pages unless you set tighter expectations around the critical path. For example, a p95 checkout target of 250 ms for an internal API call is a value to tune with production data, not a universal standard, because the right threshold depends on downstream calls and user-visible latency.

Do not multiply test jobs blindly for every flag combination because the cost grows faster than the information gained. Instead, pairwise testing with a tool such as Microsoft PICT or a constrained matrix in GitHub Actions is often enough because most flag bugs are caused by interactions between two decisions, not all decisions at once. Where money moves or irreversible records are written, add explicit scenario tests because the business cost justifies the extra runtime.

A managed flag service wins only after you define deletion rules

The common procurement mistake is buying LaunchDarkly, Split, ConfigCat, or Harness Feature Management to compensate for weak ownership. That fails because SaaS makes toggles easier to create, while the scaling problem is that nobody deletes them.

There is a real comparison to make, though. LaunchDarkly wins when you need audit trails, percentage rollouts, segment targeting, SDK maturity, and non-engineer visibility, because a 25-person engineering organization can spend more on coordination than on the subscription. Its cost is vendor coupling, recurring spend, and the temptation to let product managers create long-lived behavioral forks without code ownership.

OpenFeature 1.x with Unleash 5.x wins when you want a standard API and more control over hosting, because OpenFeature separates evaluation calls from the provider and Unleash gives teams a concrete backend without tying application code to one vendor. Its cost is operational responsibility, provider configuration, and weaker out-of-the-box governance unless you build checks for expiry, ownership, and approvals.

Flipt 1.x is a credible lighter option for teams that prefer Git-friendly configuration, because reviewing flags near code can reduce hidden state. The cost is that runtime targeting and business-user workflows may be thinner than a commercial platform. A plain environment-variable approach using Kubernetes ConfigMaps or Helm values works for coarse ops toggles, because it keeps deployment state explicit, but it is a poor fit for per-tenant rollout because redeploying to change behavior couples release and exposure.

My default for a 15-30 person organization is OpenFeature in application code even if the first provider is simple, because it prevents the flag API from becoming tomorrow’s migration trap. In Java, that means the OpenFeature SDK around service boundaries; in Node.js, it means avoiding hand-rolled “if process.env.NEW_FLOW” checks scattered through Express handlers. In .NET, the Microsoft.FeatureManagement library can be fine for internal apps, but I would still document flag classes because configuration toggles and release toggles need different retirement rules.

The platform choice matters less than one policy: no release flag merges without an owner, a removal issue, and telemetry. That sounds bureaucratic, but it is cheaper than rediscovering during an incident that Grafana shows elevated 500s while nobody knows which cohort uses the new path. OpenTelemetry 1.32 span attributes such as “feature.checkout_v2=true” are a practical compromise because they connect behavior to traces without inventing a separate debugging surface.

The QA strategy that scales is seam-first, not suite-first

The inherited codebase tempts tech leads into a test pyramid sermon: more unit tests, fewer end-to-end tests, mock responsibly. That advice is not wrong; it is mistimed because legacy systems often lack stable seams where unit tests can express real risk.

Start with seams where behavior changes under flags. A seam can be an HTTP endpoint described by OpenAPI 3.1, a message contract on Kafka using Avro and Schema Registry, a database write behind a repository, or a payment-like boundary where idempotency matters. Contract tests with Pact 4 are more valuable than broad UI tests at those seams because they pin collaboration between services while allowing internals to remain ugly for a while.

For the UI, keep Playwright or Cypress tests narrow. A measured runtime of 12 minutes for an end-to-end suite may be acceptable for main, while 45 minutes will be skipped or quarantined by impatient teams because feedback that misses the merge window loses influence. That is not a moral failing; it is how delivery systems behave under queue pressure. Use GitHub Actions concurrency groups, Buildkite parallelism, or CircleCI test splitting when runtime blocks flow, because slow safety checks create shadow deployment practices.

For back-end code, use characterization tests where you need to preserve behavior and approval tests where outputs are large. Golden-master testing is unfashionable, but it is honest for inherited code because you often need to capture current behavior before deciding whether it is correct. Add mutation testing with PIT 1.15 for Java or StrykerJS 8 for TypeScript only after the seam is stable, because mutation scores are noisy when tests are still documenting unknown behavior.

Standards help when they narrow arguments. OpenAPI 3.1 gives reviewers a contract for HTTP changes. Semantic Versioning 2.0.0 helps library consumers understand compatibility. RFC 9110 gives precise language for HTTP semantics, so teams stop debating whether a retrying POST is harmless. OWASP ASVS 4.0.3 is useful for security-sensitive legacy paths because it turns “be careful” into checkable requirements. These are not process decorations; they reduce interpretation drift as the team grows.

The QA function also has to change shape. In a 20-person engineering organization, a separate QA gate becomes a bottleneck if it owns quality after development, because it receives risk too late to influence design. Keep exploratory testing, but attach QA expertise to flag plans, observability, and rollback drills. A tester asking “what metric proves this flag is safe at 10%?” is more scalable than a tester manually repeating regression scripts after every merge.

I would not freeze delivery for a grand test rewrite

I would not stop feature work for three months to retrofit a comprehensive unit test suite, because the organization will spend its best domain knowledge encoding behavior it may soon delete. This is the position many engineers dislike, since a freeze feels responsible. In practice, freezes often produce brittle tests around today’s architecture and leave the team with the same release anxiety plus a slower CI pipeline.

A better scaling move is a ratchet. Require tests and telemetry on changed seams, enforce flag metadata in CI, and delete one old branch for every risky new branch introduced. The rule is deliberately mechanical because mechanical rules survive product pressure. If a team adds “checkout_v3,” it must remove “checkout_v1” or explain why the older path is now durable configuration.

Use architectural decision records for the disputed cases. An ADR in the Michael Nygard style is small enough to write during real work, and it gives future engineers the reason a flag became permanent. Pair that with CODEOWNERS so the owning team reviews changes, and with a Prometheus metric such as “feature_flag_evaluations_total{flag=’checkout_v2′,variant=’on’}” so production tells you whether the code path is still used.

The operating cadence can be simple. Once per week, spend 30 minutes reviewing the oldest flags, the noisiest flaky tests, and any rollout that lacks a rollback signal. That meeting is a tuneable ceremony, not a maturity badge, because the point is to remove ambiguity before it spreads. If the review produces no deletions or ownership changes for four weeks, cancel it and move enforcement back into CI because meetings without decisions train people to ignore risk.

For a 15-30 person group, scale breaks where ambiguity accumulates: ownerless flags, unmeasured variants, and tests that preserve behavior nobody wants. The first concrete move is to inventory every live flag this week, add owner and expiry fields, and fail CI for missing metadata. Do that before arguing about the perfect test pyramid, because you cannot test a production shape you cannot name.