Keep your module tree to one level of child modules and pass shared things, such as a network, in as input variables instead of letting each module create its own. HashiCorp recommends both, and both make a change easier to read in review.
What the guidance says
HashiCorp's composition page strongly recommends keeping the module tree flat, with only one level of child modules, and connecting modules with expressions in the root module. It describes dependency inversion: pass dependencies in as input variables rather than having a module create its own network or other shared resources. That keeps modules small and lets them coexist with other infrastructure.
On the module development page, the advice is similar. Modules can call other modules, but prefer composition over deeply nested modules, because flatter modules are easier to reuse in different combinations.
Why review gets harder with depth
A pull request that changes a module four levels down shows a small diff. Its effect can be large, because the change travels up through every parent. The reviewer has to trace which callers use it, with what inputs, and what resources will change.
In a flat tree, the root module shows every child and what is passed to it. The reader sees the wiring in one file.
| Property | Deep tree | Flat tree |
|---|---|---|
| Where wiring lives | Scattered across levels | Root module |
| Blast radius of a leaf change | Hard to see | Visible from root |
| Reuse in other combinations | Difficult | Easier |
| Review effort | High | Lower |
Passing objects, not building them
HashiCorp suggests declaring an object type with only the attributes the module needs. Any object with at least those attributes is accepted, whether it came from a managed resource or a data source.
variable "network" {
type = object({
vpc_id = string
subnet_ids = list(string)
})
}
The module does not care how the caller got those values. That makes it possible to point the module at a network owned by another team without changing the module.
Avoid "create if missing" logic
HashiCorp advises against building logic into a module to detect whether something exists and create it if not. The caller should pass in a managed resource if it should be created, or a data source if it already exists. This avoids complex conditional branches and makes intent readable without inspecting remote state.
For security review this is a real gain. A module that decides at plan time whether to create a firewall rule or reuse one is hard to reason about. A module that always receives its dependencies is predictable.
Data-only modules
Modules that only read existing infrastructure through data sources are useful when they hide how the data is retrieved. If the lookup changes, dependent configurations do not need to. HashiCorp also suggests mirroring the outputs of a management module, so you can swap between the two during refactoring.
A reviewer's checklist
- Count the levels of module calls. More than one is a prompt for questions.
- Look for resources a module creates that other stacks might also need, such as networks and key stores.
- Look for count or conditional logic that toggles creation of shared resources.
- Check input types are objects with only needed attributes, not whole resource copies.
- Confirm the root module shows the wiring clearly.
Related: when a module deserves its own repository.
Where Safeguard fits
Safeguard policies and gates can require reviewers on pull requests that touch shared infrastructure repositories, and evidence automation can record the approvals. The structure advice here is design guidance for your team.
Quick check
Is nesting modules forbidden?
No. HashiCorp says modules can call other modules, but recommends keeping the tree relatively flat.
Who should create the network for a module?
Usually the caller. HashiCorp recommends passing such dependencies in as inputs rather than having each module create its own.
Sources
Last reviewed: 19 October 2026.