Composition and Nested Modules
Real systems are built by composing modules — a root module calling a network module, a compute module, and a data module, wiring one's outputs into another's inputs. The art is keeping the composition shallow and the data flow one-directional. Modules calling modules calling modules become impossible to reason about or debug well before they technically break.
Composition is where modules stop being a convenience and become an architecture. Done well, the root reads like a wiring diagram of the system. Done badly, a single change means opening five directories to trace where a value comes from, and a dependency cycle appears at apply time with no obvious cause.
The Composition Pattern
The root module is the wiring layer. It calls a handful of focused child modules and connects them by passing the outputs of one as the inputs of the next — the network module's subnet IDs become the compute module's input. The children do not know about each other; the root holds the knowledge of how they fit together. That keeps each module reusable in isolation and confines the system-specific wiring to one place.
module "network" { source = "./modules/vpc" cidr_block = "10.0.0.0/16" az_count = 3 } module "compute" { source = "./modules/asg" # one module's output becomes another's input subnet_ids = module.network.private_subnet_ids instance_type = "t3.medium" }
Flat vs Deeply Nested
A child module can call its own children, and a little of that is fine. The failure mode is depth: a root that calls a module that calls a module that calls a module, three or four levels down. To change one value you open every level to trace it, and an output at the bottom has to be re-exported through every layer above to reach the top. Most teams favor a shallow tree — a root composing a small number of single-purpose modules — over a deep one, because shallow stays legible.
Data Flow Between Modules
Wire modules with the specific values each needs, and keep the flow in one direction. module.network.private_subnet_ids feeds module.compute, which produces an autoscaling group name that feeds module.monitoring — a clean one-way chain. Passing a whole undifferentiated object between modules instead of the two values one actually needs couples them tightly: the consumer now depends on the entire shape of the producer, and a change anywhere in it ripples through.
The Wrapper Module Pattern
A wrapper is a thin internal module that wraps a third-party one to standardize its use. Instead of every team calling the community VPC module directly with its full surface, they call your modules/network wrapper, which sets your tags and naming, pins the upstream version, and exposes only the inputs your org allows. It is one level of nesting that earns its keep — a single point to upgrade and enforce standards — as opposed to the gratuitous depth of modules nested for no reason.
Avoiding Hidden Cycles
Module wiring can create a dependency cycle that is invisible in any single file. If module A takes an input from module B and module B takes an input from module A, Terraform sees a loop and fails — but because the edges are spread across two module blocks, the cause is not obvious from either one alone. Designing module interfaces so data flows one direction prevents this; when a cycle does appear, the fix is to break the bidirectional wiring, often by moving a value up to the root, not to paper over it with depends_on.
- Nesting modules three or four levels deep, so a single change means opening five directories to trace the data flow and re-exporting outputs through every layer.
- Wiring module outputs back and forth until a dependency cycle appears at apply time, with the two edges spread across separate blocks and no obvious cause.
- Passing a huge undifferentiated object between modules instead of the specific values each needs, coupling them so any change to the producer ripples through.
- Building a speculative composition layer before there is a second consumer to justify it, adding indirection that serves no real reuse.
- Breaking a cycle with
depends_oninstead of fixing the bidirectional wiring, masking the design problem rather than removing it.
- Keep the module tree shallow: a root that composes a small number of focused, single-purpose modules rather than deep chains.
- Wire modules with specific output-to-input connections, keeping the data flow one-directional and acyclic.
- Pass the exact values a module needs, not a whole object, so consumers do not depend on a producer's full shape.
- Use a wrapper module to standardize a third-party module — one deliberate level of nesting — instead of deep gratuitous nesting.
- Let real reuse drive when you add a composition layer, and break any cycle by reshaping the data flow, not with
depends_on.
Knowledge Check
What is the root module's role in composition?
- It is the wiring layer — it calls focused child modules and connects them by passing one's outputs as another's inputs
- It holds no resources of its own and merely stores the shared state file on behalf of all of the child modules it calls
- It forces every child module to share one single provider configuration with no aliases
- It exposes each child's internal resources to every other child module automatically
What is the trade-off of deeply nested modules versus a shallow tree?
- Deep nesting hides the data flow — a change means opening every level and re-exporting outputs up the chain — while a shallow tree reads clearly
- Deep nesting is faster to apply because Terraform parallelizes the graph by module depth rather than by resource dependencies, so each extra level adds concurrency
- Terraform refuses to plan a configuration where modules are nested more than two levels deep
- A shallow tree cannot share outputs between its sibling child modules
How can module wiring create a hidden dependency cycle?
- Module A takes an input from B while B takes an input from A, so the loop is spread across two blocks and invisible in either alone
- Two modules that are passed the same provider configuration will always form a cycle
- A module that declares more outputs than it declares inputs creates a dependency cycle, since the extra exported values feed back on themselves
- Cycles only happen when a single module recursively calls itself in its own source
Why is the wrapper module pattern worth its one level of nesting?
- It gives a single point to set standards, pin the upstream version, and constrain the interface for every caller
- It removes the need to pin the wrapped upstream module to any specific version
- It lets callers reach the wrapped module's internal resources and read their attributes directly without outputs
- It converts a Registry module into a local-path source to make applies run faster
You got correct