skip to content

Composition Patterns

Good Terraform composes small modules in a flat root config rather than nesting them four deep. Knowing when not to write a module is as valuable in an interview as knowing how.

part ofTerraformoverview, primer and where to startread it →
on this pageshow

questions

5

In a Terraform repository, why is composing several small modules in one flat root configuration usually preferred over nesting modules three or four levels deep?

level: middleimportance: must knowfreq 62%

answer

  1. root config is the wiring layer
  2. depth costs plumbing, not logic
  3. every level re-declares and re-exports
  4. module.a.module.b.module.c addresses
  5. pass sibling outputs instead of nesting

basics

~20 s

Flat composition keeps the dependency wiring and the resource addresses visible in one place. Deep nesting threads every input and output through pass-through variables at each level, hides what is actually created, and makes review, targeting and refactoring much harder.

solid answer

~50 s

I treat the root configuration as the wiring layer: it calls a handful of purpose-built modules and feeds one module's outputs into the next module's input variables, so every dependency edge is explicit in one readable file. Depth costs real money. A value the deepest module needs must be declared as a variable and forwarded by every module between it and the root, and an id produced at the bottom must be re-exported by an `output` block at each level to be usable up top — one logical change becomes edits in four directories. Deep trees also bury the infrastructure: plan addresses grow to `module.platform.module.app.module.compute.aws_instance.web`, `-target` and console lookups get unwieldy, and a reviewer can't tell from the root what will be created. I aim for the root plus one, occasionally two, levels of children.

code

hcl · 14 lines
hcl
module "network" {
  source = "./modules/network"
  cidr   = "10.0.0.0/16"
}

module "database" {
  source     = "./modules/database"
  subnet_ids = module.network.private_subnet_ids
  identifier = "orders"
}

output "database_endpoint" {
  value = module.database.endpoint
}

go deeper

for a junior

Know that a root configuration calls child modules with module blocks, and that you connect them by passing module.NAME.output into another module's input variable. Be able to point at that reference as the thing that orders the work.

for a middle

Be ready to explain the mechanics of depth: a variable must be declared and forwarded at every level, an output re-exported at every level, and the full path shows up in plan and state addresses. Say why that plumbing is where defaults silently shadow intent.

for a senior

Show the production judgment: demonstrate dependency inversion — modules take ids rather than building their dependencies — and explain how flat wiring makes review, targeting and incident debugging tractable when someone is reading a plan at 3am.

for a principal

Own the estate-level tradeoff: where reuse layers are worth their versioning cost, when a shared low-level module's interface change becomes a coordinated release across teams, and why depth is often a symptom of missing module scope rather than a structuring win.

## What "composition" means in Terraform The directory you run `terraform plan` in is the **root module**. Any `module` block inside it calls a **child module** — another directory of `.tf` files — with a `source` argument and a set of input variables. Composition is the practice of having the root call several small child modules side by side and wire them together by passing one module's output into another's input. Nesting is the opposite shape: the root calls one module, which calls another, which calls another, and the real resources sit at the bottom of the tree. Both shapes produce the same infrastructure. The difference is entirely about what a human can see and change. ```hcl # root main.tf — flat composition, wiring visible in one file module "network" { source = "./modules/network" cidr = "10.0.0.0/16" } module "database" { source = "./modules/database" subnet_ids = module.network.private_subnet_ids } ``` The reference `module.network.private_subnet_ids` is not just data passing: it is what creates the dependency edge in Terraform's graph, so the network is created before the database without anyone saying so explicitly. ## Depth taxes every value that crosses it Suppose the deepest module needs a new input, `kms_key_arn`. In a flat layout you add one variable and pass it in the root. Three levels down you add a `variable` block in the leaf module, another in its parent, another in the grandparent, and an argument at each `module` call site — four files edited for one value, none of which contains any logic. The same applies in reverse: an id produced at the bottom is invisible to the root until every intermediate module re-exports it with its own `output` block. That plumbing is where bugs hide. An intermediate module usually has a `default` on the forwarded variable, so a value the root forgot to pass is silently replaced by whatever a middle layer decided months ago, and the plan looks fine. ## The graph you can no longer see Module boundaries are not evaluation boundaries. Terraform flattens the whole tree into a single dependency graph and a single state file; a module is a namespace, not a sandbox. So depth buys no isolation — it only removes information: - **Review.** A pull request that changes the root gives no clue what exists three levels down; the reviewer has to open four directories to answer "what does this create?". - **Addresses.** Plan output, errors, `-target=...` and state addresses all carry the full path, `module.a.module.b.module.c.aws_instance.web`. Anything you do by address becomes long and error-prone. - **Blast radius.** Changing a shared low-level module's interface forces a matching change in every level above it, and if those levels are separately versioned, a coordinated release train for a one-line change. ## Where nesting is still correct The rule is "shallow", not "never". One level of internal reuse inside a module is normal and healthy — a `service` module that calls a small shared `iam-role` module because every service needs the same role shape. What goes wrong is nesting used as *organisation*: splitting the root into layers because the file felt long, or building a `platform` module whose only job is to call three other modules with arguments it forwards unchanged. That layer adds a name and a version and no behaviour. ## Dependency inversion is the flat alternative The move that keeps configurations flat is to stop letting a module build its own dependencies. Instead of a `database` module that creates a VPC because it needs subnets, the module takes `subnet_ids` as an input. The root creates the network once and hands its ids to every consumer. Each module then becomes independently usable — you can point the database module at an existing VPC in a test account — and the composition, the part that differs between environments, lives in one file you can read top to bottom. ## What an interviewer is listening for A good answer names a concrete cost, not a style preference: pass-through variables at every level, outputs re-exported at every level, long addresses, and reviewers who cannot see the infrastructure from the entry point. A strong answer adds the counterweight — that one level of nesting for a genuinely shared primitive is fine, and that the fix for a long root file is usually better-scoped modules, not another layer above them.

  • Is there a case where you would deliberately nest one module inside another?
    Yes — when a module genuinely needs a shared primitive for every instance of itself, such as a `service` module that calls a small `iam-role` module. That is one level of internal reuse with real behaviour behind it. What I avoid is a wrapper layer whose only job is to forward arguments to other modules, because it adds a version and a name without adding a decision.
  • Two sibling modules both need the same VPC. How do you wire that without one calling the other?
    Invert the dependency: neither module creates the VPC. The root calls a `network` module once and passes `module.network.vpc_id` and the subnet ids into both consumers as input variables. The reference itself creates the ordering edge in the graph, both modules stay usable against a pre-existing network, and the relationship is visible in the root instead of buried in a child.
  • Does keeping the tree flat give each module its own state or its own plan?
    No. Every module in a single root configuration shares one state file and one dependency graph — a module is a namespace, not an isolation boundary. Separating blast radius means separate root configurations with separate state, which is a different decision from module depth. Flatness buys readability and easier addressing, not isolation.

saying these in an interview costs you the question

  • Claims each nested module gets its own state file
  • Says nesting isolates blast radius or limits the plan
  • Splits the root into layers just because the file is long
  • Lets a module create its own VPC instead of taking ids
  • Thinks configuration file order controls apply order

context

open as a page

In Terraform, what does adding `for_each` to a `module` block do, and how do you reference the resulting instances and their outputs?

level: middleimportance: should knowfreq 52%

basics

~20 s

for_each on a module block instantiates the entire module once per map key or set element. Each instance is addressed as module.NAME["key"], each.key and each.value are available inside the block, and the module's outputs become a map keyed by those same keys.

open as a page

A shared Terraform child module declares its own `provider "aws"` block so it can pin a region. Why is that discouraged, and what should the module and its caller do instead?

level: seniorimportance: should knowfreq 44%

basics

~20 s

A module owning its provider configuration cannot be used with count, for_each or depends_on, cannot be reused in another region, and becomes hard to remove — deleting the module block takes the provider config with it, leaving resources Terraform can no longer destroy. Declare requirements and let the caller pass configurations in.

open as a page

In Terraform, what are the consequences of putting `depends_on` on a `module` block instead of wiring modules together through their outputs?

level: seniorimportance: should knowfreq 36%

basics

~20 s

It makes every object in the module wait for everything in the target, which serialises work that could run in parallel and hides the relationship from the code. It also defers data sources inside the module to apply time, so the plan fills with "(known after apply)".

open as a page

When is writing a Terraform module premature abstraction rather than useful factoring, and how do you decide?

level: principalimportance: nice to knowfreq 40%

basics

~20 s

A module earns its place when it encodes a decision — defaults, naming, tagging, or several resources wired together — that would otherwise be repeated and drift. A module that wraps one resource and forwards every argument unchanged adds a version, an indirection and a release process while adding no behaviour.

open as a page