skip to content

Module Structure and Interface

A module's variables and outputs are its public API, and everything else should be an implementation detail. Treating it that way is what makes a module reusable instead of a shared liability.

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

questions

6

A caller of your Terraform module needs the ID of a subnet the module creates, but the module declares no output for it. Why can't the caller reference the resource directly, and what is the correct fix?

level: middleimportance: must knowfreq 65%

answer

  1. only module.<name>.<output> resolves
  2. internals are not addressable, by design
  3. add the output, do not reach in
  4. adding is safe, renaming is not
  5. grandchild values need re-exporting

basics

~20 s

Resources inside a child module are not addressable from outside it: module.<name> resolves only declared outputs, so module.network.aws_subnet.private is an error. The fix is to add an output to the module — its outputs are its public API, and adding one is a backward-compatible change.

solid answer

~50 s

Terraform deliberately encapsulates a child module. From the caller, `module.<name>.<output_name>` is the only address that resolves; there is no path to the resources inside, so `module.network.aws_subnet.private[0].id` fails to parse as a valid reference. The fix is to declare the value as an `output` in the module and reference `module.network.private_subnet_ids`. Resist the workarounds people reach for instead — hard-coding the ID, adding a `data` source in the root that re-looks-up something the module just created, or reading the module's resources out of state. Each one couples the caller to an implementation detail that the module author is free to change. The design lesson is that `variables.tf` and `outputs.tf` are the contract: adding an output is additive and safe, while renaming one, removing one, or quietly changing what it means breaks every caller. Choose outputs that are *stable identities* — IDs, ARNs, names, endpoints — rather than dumping whole resource objects that expose internals you will regret.

code

hcl · 16 lines
hcl
# modules/network/outputs.tf — the module's public API
output "private_subnet_ids" {
  description = "IDs of the private subnets, one per availability zone"
  value       = [for s in aws_subnet.private : s.id]
}

# root main.tf
module "network" {
  source = "./modules/network"
}

resource "aws_db_subnet_group" "this" {
  name = "app"
  # module.network.aws_subnet.private[0].id would NOT resolve
  subnet_ids = module.network.private_subnet_ids
}

go deeper

for a junior

Remember that values leave a module only through its output blocks, referenced as module.<name>.<output>, and that the fix for a missing value is to add an output rather than to hard-code an ID.

for a middle

Explain that encapsulation is enforced by the language — internal resource addresses simply do not resolve for a caller — and be able to critique the usual workarounds: literal IDs, re-lookup data sources, and reading another module's state.

for a senior

Show contract discipline: which outputs you would publish, why exporting whole resource objects traps you, and why silently changing an existing output's meaning is more dangerous than removing it outright.

for a principal

Own the policy across teams — who may change a shared module's interface, how additive-only evolution is enforced in review, and how consumers are given a migration path when an output genuinely must change.

## Encapsulation is the point Terraform's module system gives a child module exactly two openings: `variable` blocks for values coming in, and `output` blocks for values going out. From outside, the only expression form that resolves is: ``` module.<module_name>.<output_name> ``` There is no `module.network.aws_subnet.private[0].id`. It is not that Terraform cannot see the resource — it can, it is right there in the plan and in state as `module.network.aws_subnet.private[0]` — it is that the *configuration language* refuses to let you depend on it. Encapsulation is enforced, not advised. That is a deliberate design. If callers could reach any resource inside a module, then every resource, every attribute and every `for_each` key would be part of the module's public surface, and no author could ever refactor. With outputs, the author decides what is promised. ```hcl # modules/network/outputs.tf output "private_subnet_ids" { description = "IDs of the private subnets, one per availability zone" value = [for s in aws_subnet.private : s.id] } ``` ```hcl # root resource "aws_db_subnet_group" "this" { name = "app" subnet_ids = module.network.private_subnet_ids } ``` ## The workarounds, and why each is worse When the output is missing, people reach for one of these: **Hard-code the value.** Paste the subnet ID as a literal string. It works until the subnet is replaced, and it silently makes the configuration environment-specific — the same code will point at production's subnet from staging. **Add a data source in the root.** Look the subnet back up by tag or name filter. Now there is a hidden coupling to the module's tagging scheme, and a dependency Terraform cannot order correctly: the data source may read before the resource exists on the first apply, or return a stale value. **Read it out of state.** Parse `terraform show -json`, or point a `terraform_remote_state` data source at the state and dig into the module's internal resources. This makes the internal layout of another team's module into a contract they never agreed to, and it breaks the moment they rename a resource. **Fork the module.** The heaviest cost of all, for one missing line. The correct fix is a one-line pull request against the module. Adding an output is *additive*: no existing caller changes behaviour, no plan diff appears for anyone. This is the cheapest kind of interface change there is, and a module maintainer should accept them readily. ## Outputs propagate one level at a time Encapsulation applies at every level, so a value from a grandchild module is not automatically visible to the grandparent. If `modules/platform` calls `modules/network`, and the root calls `modules/platform`, then to expose the VPC ID the root must see, `platform` has to re-export it: ```hcl # modules/platform/outputs.tf output "vpc_id" { value = module.network.vpc_id } ``` This pass-through plumbing is real work at each layer and is one of the concrete costs of a deep module tree. ## Designing the output surface A few rules of thumb separate a module that ages well from one that becomes a liability: - **Export stable identities, not implementation shapes.** `vpc_id`, `cluster_endpoint`, `role_arn`, `security_group_id`. These are the values other configuration genuinely needs. - **Be careful exporting whole objects.** `output "bucket" { value = aws_s3_bucket.this }` is convenient and then permanent: every attribute of that resource becomes something a caller may be relying on, and you will not know which. - **Give every output a `description`.** It is what documentation generators publish and what a consumer reads first. - **Name for meaning, not for the resource.** `private_subnet_ids` survives a refactor from `aws_subnet` to a different construction; `aws_subnet_private_ids` advertises the implementation. - **Prefer plural collections with predictable shapes.** A map keyed by a meaningful key (availability zone, environment name) is far easier for a caller to consume than a list whose order depends on internal iteration. ## Changing an existing output Adding is safe; the other three operations are not. Removing an output breaks any caller referencing it, with an immediate configuration error — loud, but breaking. Renaming is removal plus addition. The dangerous one is *changing the meaning or type* of an existing output — switching a list to a map, or making `subnet_ids` return public subnets where it used to return private ones. That produces no error at all, just a plan that quietly rewires production. Treat the set of outputs, their names, their types and their semantics as the thing consumers depend on, because it is.

  • Is adding a new output to a widely used module a breaking change?
    No — it is purely additive. Existing callers reference outputs by name, so a new one changes nothing for them and produces no plan diff. Removing an output, renaming it, or changing its type or meaning are the breaking operations, and the last of those is the most dangerous because it errors nowhere and silently rewires callers.
  • Why is exporting an entire resource object as an output risky?
    Because every attribute of that object becomes part of your contract, and you cannot tell which ones callers use. Any future refactor — a different resource type, a wrapper, a computed value — becomes a breaking change. Exporting the two or three identities callers actually need keeps your freedom to change the implementation.
  • How do you expose a value from a module that is nested two levels deep?
    Each level must re-export it: the intermediate module declares an output whose value is `module.<child>.<output>`, and the root then reads the intermediate's output. Encapsulation applies at every level, so there is no skipping. This pass-through plumbing is one of the concrete costs of nesting modules deeply.

saying these in an interview costs you the question

  • Tries to reference module.name.aws_instance.foo from the caller
  • Adds a data source to re-look-up what the module just made
  • Hard-codes the ID rather than adding an output
  • Forks the module instead of sending a one-line PR
  • Thinks renaming an output is a harmless tidy-up

context

open as a page

In Terraform, what is the difference between the root module and a child module, and what can only the root module do?

level: middleimportance: must knowfreq 70%

basics

~20 s

The root module is the directory Terraform is run in; a child module is a directory called from a module block. Only the root takes backend configuration, tfvars and CLI variable values, and the provider configuration for the run.

open as a page

Does Terraform care whether your configuration is split into main.tf, variables.tf and outputs.tf, and why is that layout worth keeping anyway?

level: juniorimportance: should knowfreq 55%

basics

~20 s

Terraform does not care. It loads every .tf file in the directory and orders work from the dependency graph, so file names are purely a human convention. The convention survives because it puts a module's interface — its inputs and outputs — where a consumer can find it without reading the implementation.

open as a page

When designing a reusable Terraform module, how do you decide which input variables get a default and which are left required?

level: seniorimportance: should knowfreq 50%

basics

~20 s

Omit the default when the caller must decide — identity, placement, and anything with security or cost consequences — so Terraform refuses to run without it. Give a default only where one value is correct for every caller and being wrong is cheap and visible.

open as a page

How deep should a Terraform module tree nest, and what does each additional layer cost you?

level: principalimportance: should knowfreq 38%

basics

~20 s

Terraform imposes no limit, but keep the tree shallow — usually one layer of reusable modules under a root, rarely two. Every extra layer adds pass-through variables and outputs at each level, lengthens every resource address, and puts more distance between a reviewer and the resources being changed.

open as a page

What belongs in a reusable Terraform module's examples/ directory, and how do you keep the input and output tables in its README accurate?

level: middleimportance: nice to knowfreq 30%

basics

~20 s

examples/ holds complete, runnable root modules — one per usage pattern — that CI can init, validate and plan, so a broken interface fails the build. README tables are generated from the description fields on variables and outputs by a tool such as terraform-docs, with the generation checked in CI rather than done by hand.

open as a page