Skip to content
Kumar Chandrachooda
Azure & DevOps

Naming Is Your API

With no terraform_remote_state anywhere in 230 stacks, every cross-stack reference in this Azure estate was a literal name string - a taxonomy you could read from ls, a coupling with zero runtime dependencies, and a silent-drift hazard on every rename. Part 7 of the Terraform on Azure series.

By Kumar Chandrachooda 21 Nov 2025 6 min read
The folder name is the routing table

Here is a fact about the estate this series describes that surprises every Terraform practitioner I mention it to: across roughly 230 stacks there is not one terraform_remote_state data source. Not one. Every cross-stack reference — the shared App Service Plan a web app deploys into, the central APIM instance a function registers with, the Key Vault a discovery record lands in — is a literal name string in the consumer's locals.tf.

The conventional reading is that this is primitive. The estate ran for years at scale on it, so the more interesting reading is: naming discipline was the integration layer, and it is worth understanding as a deliberate architecture — including exactly where it cracks.

A folder name you can route on

Every deployed stack folder followed one taxonomy:

<resource-prefix>-<bu>-<tier>-<service>

The resource prefix encodes the Azure type — azfun (Function App), azapp (App Service), la (Logic App), cosdb, sqldb, sqlmi, storage, appplan, sb, agw. The bu segment names the business unit. And the tier infix encodes an API-led three-layer architecture:

  • expexperience APIs: channel-facing, shaped for a specific consumer (web, mobile, partner);
  • proprocess APIs: orchestration and composition, where business logic across systems lives;
  • syssystem APIs: thin wrappers over legacy backends — midrange systems, rules engines, document stores, e-signature providers.

So a folder called (schematically) azfun-retail-pro-payment-orchestrator tells you, before you open a single file: it is a Function App, owned by the retail unit, in the process tier, orchestrating payments. Roughly 92 functions split ~14 experience, ~55 process, ~23 system — you could read that the process tier dominated, and therefore where the architectural weight sat, straight out of ls. I have worked with architecture wikis that conveyed less than this directory listing.

The name then works overtime. The same string is simultaneously the state key (azfun-retail-pro-payment-orchestrator.terraform.tfstate), the basis of the resource name, the APIM backend id, and the Key Vault service-discovery key from Part 6. One coordinate, four systems. That compounding is the pattern's power and — hold the thought — its failure mode.

String coupling versus the alternatives

How should stack B consume something stack A created? Terraform offers a menu:

terraform_remote_state — B reads A's state outputs. Real referential integrity, but B's plan now depends on reading A's state: you have created a runtime dependency between pipelines, granted B's identity read access to A's state file (which contains all of A's secrets, not just its outputs), and coupled B to A's output schema.

Data-source lookups — B queries Azure for A's resources by name or tag. Honest and drift-resistant, but slower plans, and you still need to agree on the name or tag, so the string convention sneaks back in wearing a data block.

A registry — some external store of record. This estate's KV service-discovery records are exactly this, grown organically.

Literal strings — B hardcodes the names it needs:

locals {
  # another stack owns these; we consume by convention
  shared_plan_name    = "appplan-retail-shared-${local.env}"
  central_apim_name   = "apim-central-hub"
  central_apim_rg     = "rg-apim-central-hub"
}

The estate chose strings, and the choice bought something real: zero runtime dependencies between stacks. Any stack can plan and apply with nothing else running, no cross-stack state permissions, no ordering constraints beyond “the thing must exist”. At 230 stacks and a thousand state files, not having a dependency graph between pipelines is a genuine operational simplification.

The price, equally real: no referential integrity whatsoever. Nothing checks that appplan-retail-shared-prod still exists, or was ever spelled that way. The contract is enforced by grep and hope.

When a rename becomes silent drift

The failure mode is precise, and I watched it happen. A service gets renamed — new business term, a merger of two functions, whatever. The team renames the folder, the resource names, the APIM backend. But remember the compounding: that name was also a state key and a discovery record. In the estate, at least one stack's state key still carried its service's old name years later — the rename that never fully propagated. Nothing failed. That is the problem. A dangling string reference does not error at plan time the way a dangling terraform_remote_state would; it just quietly points at nothing, or worse, at the old thing, until an incident makes it interesting.

The rule this teaches: in a string-coupled estate, a rename is a schema migration. It needs an inventory of every serialization the name reached (folders, state keys, resource names, backend ids, vault secrets, pipeline variables), a migration plan, and a verification step. Treat “just rename it” with the suspicion you would give “just change the primary key”.

Hub, spokes, and two vocabularies

The naming story has a topology dimension. App stacks deployed into regional spoke subscriptions; the APIM they all registered with lived in a hub subscription in a different region — reached via the aliased provider from Part 1. APIM URL paths themselves followed the taxonomy — <bu>/api/<env>/<product> — so the gateway's route table mirrored the filesystem.

Then there were two business-unit estates, and here the convention shows its scar tissue: the estates used different environment vocabularies. One spoke dev/qa/int/intqa/staging/prod; the other dev/qa/qc/prod. Since the platform had only three physical spokes, deployed pipelines collapsed logical environments onto physical ones with hand-written conditional ladders:

variables:
  ${{ if in(parameters.spokeSelection, 'dev', 'qa') }}:
    stateStorageAccount: $(devStateAccount)     # qa shares dev's spoke
  ${{ if in(parameters.spokeSelection, 'staging', 'qc') }}:
    stateStorageAccount: $(stagingStateAccount) # qc shares staging's

The template library's clean indirection — one spokeSelection, one variable file per spoke — had been hand-expanded, per stack, into ${{ if }} ladders that each team maintained separately. When a vocabulary is not in the shared contract, every consumer invents a dialect, and 200 dialects is how a convention dies. If two environment vocabularies must exist, the mapping from logical env to physical spoke belongs in the PlatformVariables repo — one file, versioned, consumed by all — not in 200 pipeline conditionals.

What I'd actually recommend

Having lived with it, my position is more sympathetic than I expected:

  • Keep names as the primary coordinate. A strong naming taxonomy is the cheapest architecture documentation there is, and decoupled pipelines are worth a lot at this scale.
  • But verify the strings. A handful of data blocks with postcondition checks turns “grep and hope” into “plan-time assertion” for the few names that matter most — the shared plan, the hub APIM, the registry vault. You keep the decoupling and buy back integrity where it counts.
  • Centralize the vocabulary. Environment names, tier codes and resource prefixes are platform facts; put them in the platform JSON next to the subnet names.
  • Make renames a procedure. A one-page checklist would have prevented every dangling name I found.

Naming carried the integration story for compute. For the estate's Logic Apps, the story gets stranger: one integration equals four folders, four pipelines, four states. That is Part 8, Logic Apps, Four Folders at a Time.