One Contract, Two Clouds: Designing the Module Interface First
You own a module called object-store used by fourteen services on AWS. Leadership announces that two new products must run on Azure. The naive move is to copy the module, rename every resource, and tell callers to change source — then maintain two forks forever. The alternative is to define the contract first: inputs and outputs that express intent that exists on both clouds, with each provider's implementation hidden behind it. This follows the cross-cloud wiring work in Wiring Resources Across Clouds; where that topic connected resources across providers, this one hides them.
Start with the interface, not the resources:
variable "cloud" {
type = string
default = "aws"
validation {
condition = contains(["aws", "azure"], var.cloud)
error_message = "cloud must be \"aws\" or \"azure\"."
}
}
variable "name" {
type = string
description = "Globally unique store name, 3-24 lowercase alphanumerics."
validation {
condition = can(regex("^[a-z0-9]{3,24}$", var.name))
error_message = "name must satisfy the strictest provider naming rule: 3-24 lowercase alphanumerics."
}
}
variable "versioned" {
type = bool
default = true
}
The design discipline is visible in those variable names. Every input must be phrased as intent, never as provider syntax. versioned = true is intent; AWS spells it versioning_configuration { status = "Enabled" } and Azure spells it through blob properties. If you name a variable acl or account_tier, you have already chosen a cloud in the interface, and the abstraction is decoration. Note also how the name validation encodes Azure's stricter 24-character limit even for AWS deployments: the contract satisfies the strictest consumer so a workload can move without renaming. Inputs a provider needs but the contract has no words for — an Azure resource group, an AWS KMS key ARN — become a small context object of documented optional fields, rather than a growing pile of provider-flavored top-level variables.
Outputs follow the same rule, and this is where most abstraction modules quietly fail. Group them by how cloud-specific they are:
| Output | Contract treatment | Reason |
|---|---|---|
id, url, name |
required, cloud-neutral | every caller needs these to function |
| encryption state, versioning state | required, normalized (true/false) |
callers assert on them in their own tests |
arn, resource_id, self_link |
optional, nullable output native_id |
needed only when a caller wires IAM or RBAC |
| provider default tags, storage class enums | internal | exposing them re-couples every caller |
A provider-specific attribute must make one of two decisions: stay inside the module where it cannot leak, or surface as a documented optional output whose null value on the other cloud is part of the contract. There is no third option — a required output like arn forces Azure callers to fabricate values, and a fake value that "looks like" an ARN is worse than null because it fails later, in production.
Callers then consume the contract without knowing which implementation runs:
module "uploads" {
source = "../modules/object-store"
cloud = var.cloud
name = "uploads-${var.suffix}"
versioned = true
}
Before writing a single resource block, write down the contract's failure test: could a caller who has never used AWS or Azure produce a compliant store from the variable descriptions alone? If the answer requires reading the module's main.tf, the interface is leaking provider knowledge — go back and rephrase the inputs. Interviews on multi-cloud Terraform almost always start here, because a candidate who begins with resource blocks reveals that they design by implementation; a candidate who begins with variables and outputs designs by contract.
Switching Clouds with One Variable: Guarding Implementations with count
Terraform has no if resource syntax. Block types are static: every resource block in the configuration is part of the graph whether you like it or not. The switching mechanism is therefore count, evaluated at plan time, and the abstraction module's interior looks like two implementations side by side, each guarded into or out of existence.
resource "aws_s3_bucket" "this" {
count = var.cloud == "aws" ? 1 : 0
bucket = var.name
}
resource "aws_s3_bucket_versioning" "this" {
count = var.cloud == "aws" && var.versioned ? 1 : 0
bucket = aws_s3_bucket.this[0].id
versioning_configuration {
status = "Enabled"
}
}
resource "azurerm_storage_account" "this" {
count = var.cloud == "azure" ? 1 : 0
name = var.name
resource_group_name = var.resource_group
account_tier = "Standard"
account_replication_type = "LRS"
}
One module instance still exposes a single face through a local that resolves the active branch:
locals {
store_id = var.cloud == "aws" ? try(aws_s3_bucket.this[0].id, null) : try(azurerm_storage_account.this[0].name, null)
store_url = var.cloud == "aws"
? "s3://${local.store_id}"
: "https://${var.name}.blob.core.windows.net"
}
Three mechanics deserve attention because interviewers probe them.
First, count guards change the resource address semantics. Both clouds occupy index [0], so switching var.cloud from "aws" to "azure" produces a plan that destroys aws_s3_bucket.this[0] and creates azurerm_storage_account.this[0]. The switch is a migration, not a toggle — document that in the module README with the data-export step attached. Teams that treat it as a config flag discover mid-plan that object data does not follow the resource across providers.
Second, provider instances reach the module through the providers argument, and this is where default inheritance stops helping:
module "uploads" {
source = "../modules/object-store"
cloud = "azure"
name = "uploads-prod"
providers = {
aws = aws
azurerm = azurerm.eastus
}
}
A child module inherits the default provider configuration from its caller automatically — that part is invisible. What inheritance cannot do is select an aliased instance; only the providers map can hand azurerm.eastus to the child's azurerm references. The child's required_providers block must declare both providers, even though only one will ever manage resources in a given run, because the graph validates both before count prunes them.
Third, count beats for_each here on purpose. You want exactly one active implementation per module instance and a stable address; iterating for_each = toset([var.cloud]) produces the same plan but hides intent behind a set expression. When a reader has to decode your selection logic, the abstraction has traded one confusion for another. Both providers also need version constraints in the module's required_providers — aws ~> 5.0, azurerm ~> 3.0 — so a caller cannot satisfy one branch with a provider major the other branch was never tested against.
The pitfalls are concrete. Azure storage account names must be 3–24 lowercase alphanumeric characters and globally unique; AWS buckets allow 3–63 with hyphens. Your guarded implementations will disagree about valid input, so the variable validation — written once, in the contract — enforces the intersection. Guarded sub-resources compound: an Azure storage container, a versioning resource, and a lifecycle rule each carry their own count expression, and one operator typo (var.cloud == "azur" on a single block) yields a plan where half the resources silently vanish. count = 0 is not an error; it is a quiet absence. Counter it with plan-unit tests from Layering Tests in the Pipeline: assert that cloud = "aws" produces exactly the expected address list, and a mistyped guard fails the pull request instead of passing review.
The honest part of this pattern is what it cannot do — that is the next chapter's subject.
When Callers Ask for ARNs: Classifying Leaks and Pricing Lock-In
A caller opens an issue against your abstraction module: "we need the bucket ARN to attach an IAM policy." You add output "arn", and on Azure it has nothing to return. Returning a mangled string that resembles an ARN buys silence now and a production failure later; returning null forces every caller to branch on var.cloud anyway. Six months later half your root modules contain if cloud == "aws" blocks, and the abstraction is a directory name, not a design.
This is Leaky Abstraction Law applied to infrastructure: the provider differences are real, and no module boundary makes them disappear. The useful skill is classifying leaks so you can decide — consciously — which ones you accept.
| Leak type | What it looks like | Concrete example |
|---|---|---|
| Attribute | caller needs a provider-native field | IAM policy needs arn; Azure uses RBAC resource_id |
| Behavior | same API, different semantics | S3's historical eventual consistency vs strong consistency; retention locks differ in legal wording |
| Capability | feature exists on exactly one cloud | VPC gateway endpoints vs Private Link — different topology, not a rename |
| Vocabulary | no shared words at all | IAM policy documents vs Azure RBAC role assignments vs GCP IAM bindings |
Handle leaks in this order of preference. First, keep it internal: if only your module needs the ARN to build a policy, build the policy inside the module and don't expose the attribute. Second, surface it as an optional, nullable output — native_id — with a docstring stating plainly that reading it on one cloud returns null and that depending on it re-couples the caller to that provider. Third, and rarely: admit the abstraction is wrong for that workload and hand the team the raw provider module alongside the wrapper. An honest two-track setup beats a universal module that grows twelve escape-hatch variables.
That brings us to the accusation every abstraction design eventually faces: cloud lock-in is a deliberate trade-off, not an accident to be engineered away. Abstraction costs real money on every change — you now implement twice, review two diffs, run a doubled test matrix, and delay each feature by the time it takes to prove both branches. It buys portability you may exercise once in a decade, if ever. Companies that actually exit a cloud do it through their data (open formats, export pipelines, rehearsed restores) and their delivery process (CI that can target another provider), not through which module call created the bucket. Spend your portability budget where an exit would genuinely happen; abstracting arn out of view does not move a petabyte.
Know when abstraction is worth it: commodity capabilities — object storage, queues, managed secrets — where the overlap is 80%+ and the API surface is stable. Know when it is wrong: when the cloud-native feature is why you chose that cloud. Nobody should abstract BigQuery behind a "generic SQL" Terraform interface; the abstraction would delete the advantage you paid for.
Interviewers ask this as "how would you make your Terraform multi-cloud?" The strong answer has three beats: contract first (inputs as intent), count guards second (plan-time selection with explicit migration semantics), and leaks third — named, classified, and consciously accepted. With the mechanism understood, the harder question is whether the second cloud earns its cost at all: Multi-Cloud Design Tradeoffs.
Quiz
1. Inside a module that must create either an aws_s3_bucket or an azurerm_storage_account based on var.cloud, how do you select the implementation at plan time?
2. The abstraction module must manage resources through the aliased provider azurerm.eastus. How does the child module receive it?
3. A caller needs the bucket ARN to build an IAM policy, but the Azure implementation has no such identifier. What should the contract do?
4. You change cloud = "aws" to cloud = "azure" in a module that already has state. What plan do you get?
Flashcards
Question
What makes an input variable cloud-neutral, and what is the tell that it is not?
Click to reveal answer
Answer
It expresses intent both providers can satisfy: versioned = true, name = "...". The tell is provider syntax in the name — acl, account_tier, kms_key_id — which means the cloud has leaked into the contract and every caller now needs a var.cloud branch.
Question
How does a child module receive an aliased provider instance?
Click to reveal answer
Answer
Via the providers argument on the module block, mapping the child's provider name to the caller's aliased configuration, for example providers = { azurerm = azurerm.eastus }. Default inheritance passes only default configurations — aliases must be listed explicitly.
Question
Name the four kinds of abstraction leak with one example each.
Click to reveal answer
Answer
Attribute: caller needs a native field (arn vs resource_id). Behavior: same API, different semantics (consistency and retention rules). Capability: the feature exists on one cloud only (VPC gateway endpoint vs Private Link). Vocabulary: no shared words at all (IAM policy documents vs Azure RBAC).
Question
Why is count the right cloud selector instead of for_each or dynamic?
Click to reveal answer
Answer
You want exactly one active implementation at a stable address; count = condition ? 1 : 0 prunes the other branch from the graph during plan. dynamic only drives nested blocks, and for_each over a set of clouds obscures intent without adding capability.
Question
What happens to state when var.cloud flips from aws to azure, and what must the docs say?
Click to reveal answer
Answer
The plan destroys the AWS addresses and creates the Azure ones; data does not follow the resource across providers. The docs must call the switch a migration and spell out the export-then-import step, because treating it as a config flag loses whatever the store contained.
Revision Notes
Key Takeaways
- 1. Design the contract before any resource block: inputs phrased as intent, validated against the strictest provider rule so workloads can move without renaming
- 2. Provider-specific attributes must stay internal or surface as optional nullable outputs — a fake ARN-shaped value fails later than null does
- 3. count ternaries are the plan-time selector; the providers argument is the only way to hand an aliased provider instance to a child module
- 4. Every leak gets classified — attribute, behavior, capability, vocabulary — and each has a ranked response instead of an ad-hoc variable
- 5. Lock-in is a deliberate trade-off: the doubled implementation taxes every change, so spend portability effort on data and pipelines, not on hiding provider syntax
Interview Tips
- • "How would you make your Terraform module work on AWS and Azure?" — answer in three beats: contract first (variables as intent), count guards second (plan-time selection with explicit migration semantics), leaks third (named and consciously accepted)
- • "What makes an abstraction leaky in IaC?" — give the four-category taxonomy with one concrete example each; interviewers reward vocabulary that turns a vague complaint into a classifiable event
- • "How does a module get an aliased provider?" — the providers argument; add that default inheritance works silently and that this distinction is where most multi-cloud module code breaks
- • "Should we abstract away a cloud-native service?" — usually no: name the commodity cases where abstraction wins (object storage, queues) and refuse for features that are the reason you chose the cloud, like BigQuery
Cheat Sheet
Abstraction Modules Across Clouds
Contract rules:
- Variables express intent (
versioned = true), never provider syntax (acl,account_tier) - Validate against the strictest provider (Azure's 3-24 lowercase alphanumeric name rule)
- Outputs: cloud-neutral ones required (
id,url), provider-native ones nullable (native_id), everything else internal
Selection mechanism:
resource "aws_s3_bucket" "this" {
count = var.cloud == "aws" ? 1 : 0
bucket = var.name
}
locals {
store_id = var.cloud == "aws" ? try(aws_s3_bucket.this[0].id, null) : try(azurerm_storage_account.this[0].name, null)
}
module "uploads" {
source = "../modules/object-store"
cloud = "azure"
providers = { aws = aws, azurerm = azurerm.eastus }
}
Leak ladder: internalize → expose nullable native_id with a coupling warning → hand the caller the raw provider module. Never fake values.
Lock-in verdict: deliberate trade-off. Portability is bought with data exports and CI targeting, not hidden arn outputs. Abstract commodity stores (80% overlap); never abstract the feature you chose the cloud for.
Switch semantics: changing var.cloud plans destroy + create — it is a migration with an export step, not a toggle.