Skip to content
advanced Phase 7 · Module Design and the Module Registry

Composing Modules into Layers

Structuring root modules, shared modules, and wrapper modules for scale.

1h 5m
0 problems
Topic Progress 0%

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?

Question 1 options

2. A plan shows a diff at `module.app.module.network.module.subnets.aws_subnet.private[2]`. Which change most improves debuggability?

Question 2 options

3. What is the primary job of a wrapper module?

Question 3 options

4. Two modules in the same root need to agree on the VPC. What is the correct coupling?

Question 4 options

Flashcards

Question

What belongs in an environment root versus a shared module?

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?

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?

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?

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?

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).

Continue Learning