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.
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:
exp— experience APIs: channel-facing, shaped for a specific consumer (web, mobile, partner);pro— process APIs: orchestration and composition, where business logic across systems lives;sys— system 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
datablocks withpostconditionchecks 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.