Composition and Nested Modules
Modules call modules, and composition is how you build something real. The Hatch root module calls the ingest-pipeline module, which could itself call the CFT cloud-storage module for its raw bucket. Assembling a large system from small, single-purpose pieces is the whole point of the module concept — but composition has two failure modes, and both are worth naming before you write a nested module.
The first is passing providers to a child incorrectly. The second is nesting too deep: a module tree more than two or three levels down becomes impossible to reason about, and you find yourself debugging through layers of indirection instead of looking at resources. This topic covers both, and the rule that keeps you out of trouble is to compose flatly and pass providers only when inheritance genuinely cannot express what you need.
Modules Calling Modules
A child module can contain its own module blocks, so the ingest-pipeline module could compose the CFT cloud-storage module for its raw bucket instead of declaring a google_storage_bucket directly. That is composition: the pipeline module assembles smaller reviewed pieces, and the root module assembles pipeline modules. Each layer wires the outputs of one piece into the inputs of the next, building up from primitives to a whole system.
Implicit Provider Inheritance
By default a child inherits the caller's default provider configurations, so most modules need no provider plumbing at all. The root configures the google provider once, and every child below it uses that configuration automatically. This is the common case and the simple one: you write a module block, pass the input variables, and the provider is handled for you without a single line about it.
google provider with no plumbing — the common case.providers argument plus configuration_aliases, only for a second region or google-beta.Passing Providers Explicitly
When a child needs a non-default provider — a second region, a different project, or google-beta for a resource only the beta provider exposes — you pass it explicitly with a providers argument in the module block. The child declares it expects to receive one through configuration_aliases, and a reusable child must receive providers this way, never declare its own provider block. The snippet below passes google-beta into a child that needs it.
# root: configure both the default and a beta provider provider "google-beta" { project = "hatch-pipeline-prod" } module "ingest_pipeline" { source = "./modules/ingest-pipeline" providers = { google-beta = google-beta # hand the beta provider to the child } event_name = "clickstream" } # child: declare it RECEIVES a provider — never declare a provider block terraform { required_providers { google-beta = { source = "hashicorp/google-beta" configuration_aliases = [google-beta] } } }
A reusable child that declares its own provider block cannot be instantiated twice and breaks for_each over the module, because the provider configuration gets tangled with the instance. Receiving providers through configuration_aliases keeps the child reusable; declaring them inside it does not.
Keeping the Tree Shallow
The healthy shape is flat composition: wire the outputs of one module into the inputs of the next, side by side under the root. The ingest-pipeline module's topic_id output flows into a bigquery-loader module's input, and a reader can follow that in one hop. The anti-pattern is burying modules four levels deep so a single input threads through three intermediaries that do nothing but pass it along — every one of those layers is a place to get lost and nothing to gain.
Keeping data flow one-directional matters as much as keeping it shallow. Wire module A's output into module B's input, never B's output back into A's — that back-and-forth is how a dependency cycle appears at apply time, when Terraform cannot decide which module to build first. Pass the specific values each module needs rather than handing whole undifferentiated objects around, and the flow stays acyclic and legible.
Reading a Composed Plan
Addresses nest to match the tree: a bucket two modules deep reads as module.ingest_pipeline.module.bucket.google_storage_bucket.raw. That nesting is your smell test. When a plan line is so deeply prefixed that you can no longer trace it back to a resource at a glance, the tree is too deep — the address is telling you the composition has more layers than the system actually has structure. A plan you can read is a tree you have kept shallow.
Implicit inheritance — the default, and right for the common case. A child uses the caller's google provider with no plumbing: configure the provider once in the root and every child below inherits it. Reach for this unless you have a concrete reason not to.
Explicit passing — a providers = { ... } argument with configuration_aliases in the child. Needed only when a child operates a non-default provider — a second region, a different project, or google-beta. Use it only when inheritance genuinely cannot express what you need.
- Declaring a
providerblock inside a reusable child module instead of receiving it viaconfiguration_aliases— the module cannot be instantiated twice and breaksfor_eachover it. - Nesting modules four or five levels deep, so changing one input means tracing it through three intermediary modules that do nothing but pass it along.
- Forgetting to pass a non-default provider to a child that needs
google-beta, then getting a confusing error that the child used the wrong provider or none at all. - Wiring module outputs back and forth until a dependency cycle appears at apply time and Terraform cannot decide which module to build first.
- Wrapping every single resource in its own module "for consistency" — a one-resource module adds an indirection layer and a state-address level for no encapsulation benefit.
- Let children inherit the default provider implicitly; pass providers explicitly only when a child genuinely needs a non-default one such as a second region or
google-beta. - Have reusable child modules receive providers via
configuration_aliases, never declare their ownproviderblocks. - Keep the module tree shallow — compose flatly by wiring outputs to inputs rather than nesting deeply.
- Wire modules with specific values rather than whole objects, and keep data flow one-directional so no dependency cycle can form.
- Use the nested state address as a smell test: if you cannot trace a plan line back to a resource, the tree is too deep.
Knowledge Check
When does a child module need a provider passed explicitly rather than inherited?
- Only when it operates a non-default provider — a second region, a different project, or
google-beta - Always — every child must receive its provider explicitly through a
providersargument - Never — children cannot use providers at all and must hand their resources back to the root to create
- Only when the child is sourced from the public Registry rather than a local relative path
Why must a reusable child module not declare its own provider block?
- It makes the module impossible to instantiate twice and breaks
for_eachover it - Provider blocks are only allowed in modules published to the public Registry, not local ones
- It would silently override the root module's backend configuration
- Terraform forbids more than one provider block across the whole configuration entirely
What does configuration_aliases in a child module do?
- Declares that the child expects to receive a provider configuration from its caller
- Renames the child's resources at apply time to avoid state-address collisions with the root
- Creates a second copy of the default provider inside the child for a different region
- Pins the exact provider version the child is allowed to use at init
Why is nesting modules four or five levels deep an anti-pattern?
- Inputs thread through intermediaries that only pass them along, and plan addresses get too deep to trace back to a resource
- Terraform caps module nesting at three levels and errors out as soon as you exceed it
- Deep nesting always creates circular dependency cycles between the layers automatically
- Each additional level roughly doubles the apply time, regardless of how many resources it actually holds or how small they are
You got correct