What Belongs in the Root Module Versus a Shared Module
A team hands you a 4,000-line main.tf that provisions three environments by copy-paste. The refactor is not "extract some modules" — it is answering one question per block: does this value describe how to build the thing, or which thing this environment builds? The inputs and outputs you designed in Designing Module Input and Output Contracts are the joints; this topic decides where the bones sit.
The answer becomes the directory layout:
repo/
├── modules/ # how to build things (environment-blind)
│ ├── network/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── database/
│ └── observability/
└── envs/ # which environment this is
├── dev/
│ ├── main.tf # module calls + wiring only
│ ├── backend.tf # state location for dev
│ └── terraform.tfvars # account id, region, sizing
├── staging/
└── prod/
Each side holds artifacts the other must never contain:
| Artifact | Lives in | Why it cannot move |
|---|---|---|
backend block |
environment root | dev and prod must never share state |
provider configuration |
environment root | credentials and regions are per-environment |
module calls + their wiring |
environment root | this is the layer that composes |
.tfvars (account id, sizing) |
environment root | differs per environment by definition |
Resources, locals, data lookups |
shared module | identical logic everywhere or it is a bug |
Internal variable defaults |
shared module | the design decision belongs with the code |
Two failure modes show up constantly. The first is secrets and identity leaking downward: an account ID or region hardcoded inside modules/network means the module only works for one environment, and the "shared" copy silently forks. Push it up as an input — if it must differ, it is a root's job. The second is provider configuration leaking upward into the child: a provider block inside a shared module forces every caller to hand it configurations through the providers meta-argument and forbids count/for_each on that call entirely. Providers configure in the root; modules consume the default (or an explicitly passed) one.
The layout also decides your state story. Three environment roots mean three states, three locks, three backends — a change to envs/dev cannot corrupt prod because prod's state is a different object, and a CI pipeline can plan only the directory the branch touched. Compare that with three workspaces sharing one configuration: cheaper to set up, impossible to give different backends, and one mistaken terraform workspace select applies prod code to staging. The deeper treatment is in Promoting Changes Across Environments; the point here is that layering is what makes environment isolation a filesystem property instead of a runbook step.
What a compliant root actually looks like is shorter than people expect:
# envs/prod/main.tf — composition and wiring, nothing else
terraform {
backend "s3" {
bucket = "acme-tfstate-prod"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "acme-tf-locks"
}
}
provider "aws" {
region = var.region
}
module "network" {
source = "../../modules/network"
name = "acme-prod"
cidr = var.vpc_cidr
az_count = 3
}
output "vpc_id" {
description = "VPC id for downstream deployments."
value = module.network.vpc_id
}
No resource blocks. Every line either selects the environment or composes a module — if you need a resource here, ask whether the shared module missed one.
Interviewers ask for this structure cold: "show me where the prod backend config goes, where the VPC resources go, where the account ID goes." Answer with the three paths above and you have demonstrated the whole model.
Flat Beats Deep: Keeping the Module Tree Two Levels for Debuggable Plans
Composition tempts you toward depth: app calls network calls subnets calls tags. Each level is locally reasonable and the result is a tree nobody can read a plan out of.
module.app.module.network.module.subnets.aws_subnet.private[2]
That address is the symptom. Everything downstream of it — plan diffs, terraform state, -target operations, incident debugging at 2 a.m. — inherits its length. The rule that keeps a configuration operable: two levels from the root. Root calls leaf modules; a leaf may call one helper; nothing nests further.
| Criterion | Flat (root → thin modules) | Deep (nested chains) |
|---|---|---|
| Plan diff readability | module.database.aws_db_instance.primary |
Six-segment addresses, repeated blocks |
| State inspection | terraform state list fits one screen |
Requires grepping and mental unwinding |
| Reuse | Each leaf usable in any composition | Leaf only reachable through its parent |
| Refactor cost | Rewire the root, done | Interface changes ripple up every level |
| Blast radius | One module owns one concern | A leaf change re-plans the whole chain |
-target debugging |
Shallow, trustworthy | Deep, easy to hit the wrong instance |
Flat, remember, does not mean "one module does everything." A flat root typically calls six to ten thin modules, one concern each:
module "network" ← VPC, subnets, route tables
module "database" ← RDS instance, parameter group
module "observability" ← log groups, alarms, dashboard
module "dns" ← zones and records
Each leaf owns one kind of resource, exposes a small contract, and knows nothing about its siblings — the composition (who calls whom, in what order) is decided entirely at the root, where you can read it in one screen. Depth hides that decision inside child files; flatness keeps it where the reviewer already is.
Depth does buy encapsulation — a caller of app need not know about subnets — but Terraform already gives you a better version of that trade: a wrapper module keeps the nesting out of your plan while keeping the interface small. The wrapper is an ordinary module that calls several leaves and fixes their defaults:
# modules/app/main.tf — one level from the root, three leaves inside
module "network" {
source = "../network"
name = var.name
cidr = var.cidr
azs = var.availability_zones
}
module "database" {
source = "../database"
vpc_id = module.network.vpc_id
subnet_ids = values(module.network.private_subnet_ids)
engine = "postgres"
multi_az = var.environment == "prod"
}
output "db_endpoint" {
description = "Connection endpoint of the provisioned database."
value = module.database.endpoint
}
From the root's perspective this is still one level: module.app.db_endpoint. Inside, the leaves are wired once, with the awkward parts (engine = "postgres", the prod multi-az rule) fixed in exactly one place instead of restated by every caller — which is the wrapper's whole justification. Do not read it as "nesting is fine now": the wrapper's internals stay flat and visible when you debug them.
When a plan surprises you, the flat tree makes the tooling usable:
terraform state list | grep '^module\.' # every instance address, sorted by layer
terraform state show 'module.database.aws_db_instance.primary'
terraform graph -draw-cycles # who depends on whom, one hop at a time
Plan: 3 to add, 1 to change, 0 to destroy.
~ module.database.aws_db_instance.primary
+ instance_class: "db.t3.large" → "db.t4g.large"
With a deep tree the same diff appears under module.app.module.database... three times, and you must confirm which of the duplicated module paths you are even looking at. Readability of the plan is not a cosmetic concern — it is the control surface for production changes, and every nesting level you add taxes it forever.
Coupling Through Outputs: Wiring Layers Without Shared Variables
Here is a root composing three leaves, complete and unremarkable — which is the point:
module "network" {
source = "../../modules/network"
name = "acme-${var.environment}"
cidr = var.vpc_cidr
az_count = 3
}
module "database" {
source = "../../modules/database"
vpc_id = module.network.vpc_id
subnet_ids = values(module.network.private_subnet_ids)
kms_key_arn = module.kms.key_arn
}
module "observability" {
source = "../../modules/observability"
cluster_name = "acme-${var.environment}"
target_arns = [module.database.instance_arn]
}
Read the wiring: every cross-module dependency is an output of one module bound to an input of another. That single rule is what keeps the dependency graph honest. Terraform infers ordering from the value flow, the plan shows a real edge instead of a hidden one, and you can delete module.network to see exactly which consumers break — they are the ones whose inputs went unbound.
Now the shortcuts teams take instead, and why each is worse:
| Shortcut | What it costs you |
|---|---|
data "terraform_remote_state" into another environment's state |
A hidden contract with no validation, plus lock contention and blast radius across environments |
Passing one giant locals/variable map to every module |
Defeats type checking and validation; every module must know every key |
| Modules reading sibling modules by full address | Siblings are not in scope — only outputs are, and code that pretends otherwise will not plan |
depends_on as the default wiring |
Ordering without meaning; hides the actual data dependency from reviewers |
The wrapper from the previous section is where these rules consolidate. Its inputs are few (name, cidr, availability_zones, environment), its internal wiring is fixed, and its outputs are exactly what callers consume. Many teams then instantiate it once per service:
module "service" {
source = "../../modules/service"
for_each = var.services
name = each.key
environment = var.environment
cidr = each.value.cidr
}
Each module.service["payments"] is an independent instance with its own state subtree — the composition scales by data, not by copy-paste.
Trying to bypass the contract fails fast, and the error teaches the model:
Error: Unsupported attribute
This object does not have an attribute named "aws_vpc".
That is what you get for writing module.network.aws_vpc.this.id from a sibling. The module.network object exposes exactly one class of attribute — its outputs — so Terraform rejects the resource address at expression evaluation, before any plan output is produced. There is no "reach in and grab it" mode, and configurations that appear to do so are always reading an output someone happened to name like a resource.
Remote state is the sneakier shortcut because it works — until it does not. It holds while both sides are healthy and nobody moves the sharing convention. It breaks when the network state changes key, when a reader runs before the writer has applied, or when a reader takes the writer's state lock and stalls the other environment's deploy. And because terraform_remote_state returns a plain object with no schema validation, a renamed output surfaces as an Unsupported attribute in a different pipeline, days later, with no link back to the change that caused it.
Be honest about the wrapper's failure mode: when every caller must override half of its fixed defaults, the wrapper has become a pass-through with extra steps, and the defaults it "fixes" were never real decisions. Fix it by either committing to the opinion (fewer inputs) or dissolving the wrapper and exposing the leaves directly. There is no third option that stays maintainable.
This is also where module distribution starts to matter: once five teams compose these same leaves, hand-copied paths stop working, and the next topic — Publishing to a Private Module Registry — is how the composition becomes a shared, versioned dependency instead of a folder everyone clones.
Quiz
1. Where does `data "aws_caller_identity" "current" {}` belong if dev, staging, and prod all need it?
2. A plan shows a diff at `module.app.module.network.module.subnets.aws_subnet.private[2]`. Which change most improves debuggability?
3. What is the primary job of a wrapper module?
4. Two modules in the same root need to agree on the VPC. What is the correct coupling?
Flashcards
Question
What belongs in an environment root versus a shared module?
Click to reveal answer
Answer
Root: backend, provider configuration, module calls and their wiring, and `.tfvars` holding account IDs, regions, and sizing — everything that differs per environment. Shared module: resources, locals, and how a thing is built. Rule of thumb: if it differs between dev and prod it goes up; if it is identical logic it goes down.
Question
Why prefer a flat module tree over deep nesting?
Click to reveal answer
Answer
Plan diffs, state addresses, and -target operations stay short and greppable (`module.database.aws_db_instance.primary` vs six segments), reuse stays high because each leaf is directly composable, and refactors rewire the root instead of rippling interface changes up a chain. Cap it at two levels.
Question
What does a wrapper module do, and when is it the wrong tool?
Click to reveal answer
Answer
It composes several leaf modules and fixes shared defaults (engine, multi-az rules) once, exposing a small input surface to many callers — depth without nesting in the plan. It is wrong when callers override most fixed defaults, at which point it is a pass-through: either commit to the opinion or dissolve it and expose the leaves.
Question
How should cross-module dependencies flow, and which shortcuts replace it badly?
Click to reveal answer
Answer
Through explicit outputs bound to inputs: `vpc_id = module.network.vpc_id`. That gives Terraform a real edge to order by. Bad substitutes: `terraform_remote_state` into other environments (hidden contract, lock contention), one giant shared variable map (defeats validation), sibling resource addresses (out of scope), and blanket `depends_on` (ordering without meaning).
Question
How do you scale a composition to many services without copy-paste?
Click to reveal answer
Answer
Instantiate the wrapper with `for_each` over a map of services — each `module.service["payments"]` gets its own state subtree and independent outputs — with per-service data in `.tfvars` and identical code underneath.
Revision Notes
Key Takeaways
- 1. The root module is a wiring diagram: backends, providers, module calls, and environment facts — resources and how-to logic live in shared modules
- 2. If a value differs between dev and prod it moves up as an input; if it is identical logic it stays down — hardcoded identity inside a module is a fork waiting to happen
- 3. Flat compositions beat deep nesting: short state addresses, readable plan diffs, higher reuse, and refactors that rewire one file instead of rippling upward
- 4. Wrapper modules buy encapsulation without nesting: fix the awkward defaults once, expose a small interface, and dissolve the wrapper when callers override everything
- 5. Cross-module coupling flows through outputs bound to inputs — remote state, giant shared maps, and sibling addresses all hide the dependency from the graph
Interview Tips
- • "Show me how you structure a Terraform repo." — give the tree, then the one-line layer test (how vs which environment), and mention that three roots mean three states, three locks, and directory-scoped CI plans
- • "How deep should modules nest?" — two levels, justified with an actual six-segment address from a plan diff; then present the wrapper as encapsulation that does not tax the plan
- • "When is a wrapper module the wrong answer?" — when callers override most fixed defaults; say you either commit to the opinion (fewer inputs) or dissolve it and expose leaves
- • "How do two modules talk?" — output-to-input, because it creates the dependency edge Terraform orders by; name the shortcuts you reject and the specific failure each one causes
Cheat Sheet
Composing Modules into Layers
Layer test: Does this value describe how to build the thing (shared module) or which environment builds it (root)?
repo/
├── modules/{network,database,observability}/ # resources, locals, how
└── envs/{dev,staging,prod}/
├── main.tf # module calls + output→input wiring only
├── backend.tf # state location (never shared)
└── terraform.tfvars # account id, region, sizing
| Belongs in root | Belongs in shared module |
|---|---|
backend, provider blocks |
resource definitions |
| module calls + wiring | locals, naming logic |
.tfvars env facts (account, region) |
input defaults and validation |
| per-env outputs to surface | outputs that expose ids/ARNs/endpoints |
Depth budget: two levels. Root → leaf → (one helper). Flat plans read as module.database.aws_db_instance.primary; deep ones read as module.app.module.network.module.subnets... and every debug command inherits the length.
Wrapper pattern:
module "service" {
source = "../../modules/service"
for_each = var.services
name = each.key
environment = var.environment
}
Fixes shared defaults once; expose only inputs callers actually vary.
Coupling rule: outputs → inputs only. vpc_id = module.network.vpc_id.
Reject: remote-state reads across environments · one giant shared map · sibling resource addresses · depends_on as default wiring.
Debug the flat tree: terraform state list · terraform state show <address> · terraform graph -draw-cycles · terraform plan -target=module.x (diagnosis only, never the apply path).