Lindsay Edwards

Not configured is a supported state

A real app accretes optional integrations: transactional email, error tracking, blob storage, payments, SMS, an accounting sync. In development you have none of their keys. In continuous integration you want none of them making real calls. In production you have some but maybe not all. The naive handling of this, a null check and an early return scattered at every call site, turns into a mess fast, and it means your code runs a different shape in tests than in production.

The pattern that worked was to make “this integration is not configured” a first-class runtime state, not an error and not a special case.

The no-op that keeps the shape#

Each integration is wrapped so that a missing key, token, or connection string selects a development no-op branch. The key detail is that the no-op still runs the whole surrounding code path. The email integration, unconfigured, still writes the queued message row, still records that a send was intended, and just skips the one outbound network call at the end. It returns a plausible success and moves on.

That does two things. Continuous integration runs entirely on the no-op branches by default, so the pipeline needs zero real credentials, and the exact same code path executes in a test as in production, minus the single network hop. You are not testing a different branch than you ship.

The environment variables get sorted into three tiers, and being explicit about the tiers is half the value:

  • Required at boot. Validated on startup; the app refuses to start without them. The database, the core secrets.
  • Deploy-time. Things like a privileged migration connection, needed by the deploy step, not the running app.
  • Optional and feature-gated. Each guarded at its call site, each degrading to a no-op when absent.

“Not configured” is a state your code should handle on purpose, not an accident it falls into. Model it as a no-op that preserves the surrounding path, and your dev, CI, and prod code stay the same shape.

The one that will bite you#

Here is the honest footnote, because this pattern has a trap. A no-op is safe when the operation is additive, a send that does not happen, a metric that is not recorded. It is dangerous when the operation is a deletion.

There was a scheduled purge job that called “delete this blob.” Without the storage token configured, that call silently becomes a no-op, so in development the purge “works” and deletes nothing, which is exactly what you want. But if that same unconfigured state ever reached production, the purge would run, report success, and quietly retain data it was legally supposed to delete. Picture a shredder that has been unplugged without anyone noticing: everybody assumes the sensitive documents are gone, and every one of them is still sitting in the bin. The no-op is correct for dev and a latent data-retention bug in prod.

So the pattern comes with a duty: keep a short list of which no-ops are dangerous if they leak into production, and make those specific ones fail loud when they are missing in a real environment. Graceful degradation is a lovely default. It is not a substitute for knowing which gracefully-degraded paths are quietly not doing something they were required to do.

Keep reading