Module Inputs, Outputs and Composition
A useful module is rarely isolated. Real infrastructure has relationships: a compute component needs a network, an application needs storage, and one component often consumes information produced by another.
Terraform modules handle these relationships through their inputs and outputs. Once you understand that interface, module composition becomes a natural extension of the dependency graph you already know.
Module Inputs Are Interfaces
A module variable is an input to the module.
For example:
variable "name" {
type = string
}A caller can provide:
module "example" {
source = "./modules/example"
name = "frontend"
}The child module receives that value as:
var.nameInputs can be simple values:
name = "frontend"but they can also be structured values.
Lists
variable "subnet_ids" {
type = list(string)
}A caller can provide:
subnet_ids = ["subnet-a", "subnet-b"]Maps
variable "tags" {
type = map(string)
}A caller can provide:
tags = {
environment = "production"
owner = "platform"
}Objects
Objects are useful when several related values form one meaningful input:
variable "server" {
type = object({
name = string
size = string
image = string
})
}The caller can then provide:
server = {
name = "frontend"
size = "small"
image = "example-image"
}The important design question is not whether Terraform supports a particular type. It does.
The question is:
What information does this module actually need?
Use types to make that interface explicit.
Required And Optional Inputs
A variable without a default is required.
variable "name" {
type = string
}The caller must provide it.
A variable with a default is optional:
variable "environment" {
type = string
default = "development"
}Now the caller can omit it.
Defaults can make a module easier to use, but defaults should represent sensible behavior rather than hiding important architectural decisions.
For example, a default for a harmless naming suffix can be useful. A default that silently selects an important production behavior may be much less appropriate.
Validate Inputs When The Rule Belongs To The Interface
If a module expects a value to satisfy a simple rule, validation can make the interface safer.
For example:
variable "environment" {
type = string
validation {
condition = contains(["development", "staging", "production"], var.environment)
error_message = "Environment must be development, staging, or production."
}
}Now invalid values are rejected before the module can proceed with an incorrect configuration.
Validation is most useful when the rule is intrinsic to the module’s contract.
Do not turn every input into a complicated collection of validation expressions just because Terraform allows it.
Module Outputs Are The Other Half Of The Interface
Outputs allow a module to provide values to its caller.
For example:
resource "aws_subnet" "this" {
count = 3
...
}
output "subnet_ids" {
value = aws_subnet.this[*].id
}In the above example, when count creates multiple aws_subnet.this instances, [*].id collects the ID of each instance into a list and exposes them as an output.
The caller can consume that value:
module.network.subnet_idsThis is a critical boundary.
The caller does not need to know how the subnet IDs were created. It only needs to know that the network module provides them.
The relationship is:
Caller
β
β input
βΌ
βββββββββββββββββββββββ
β Module β
β β
β implementation β
β β
βββββββββββββββββββββββ
β
β output
βΌ
CallerModules Can Be Composed
Now consider two modules:
Network Module
β
β subnet IDs
βΌ
Compute ModuleThe network module creates networking resources and exposes the subnet IDs.
The compute module accepts subnet IDs as an input.
The root module connects them:
module "network" {
source = "./modules/network"
name = "application"
}
module "compute" {
source = "./modules/compute"
subnet_ids = module.network.subnet_ids
}This is module composition.
The root module acts as the composition layer, deciding that the compute module should use the output from the network module.
Module Outputs Also Create Dependencies
This pattern is important because it connects directly to Terraform’s dependency graph.
Suppose:
module "compute" {
source = "./modules/compute"
subnet_ids = module.network.subnet_ids
}Terraform can see that the compute module needs a value produced by the network module.
Conceptually:
Network Module
β
β subnet_ids
βΌ
Compute ModuleThe reference creates the dependency naturally.
You generally do not need:
depends_on = [module.network]The value reference already tells Terraform what it needs to know.
Tip
Prefer expressing dependencies through actual data flow. If the compute module consumes module.network.subnet_ids, Terraform can derive the relationship from that reference.
This is the same dependency-graph principle you learned earlier, now crossing module boundaries.
Chaining Modules
You can continue the chain.
For example:
Network Module
β
β subnet IDs
βΌ
Compute Module
β
β instance IDs
βΌ
Application LayerThe root module might look conceptually like:
module "network" {
source = "./modules/network"
}
module "compute" {
source = "./modules/compute"
subnet_ids = module.network.subnet_ids
}
module "application" {
source = "./modules/application"
instance_ids = module.compute.instance_ids
}Each module has a clear responsibility:
- Network produces network information.
- Compute consumes network information and produces compute information.
- Application consumes compute information.
The root module composes them.
Avoid Unnecessary Coupling
Composition becomes problematic when modules expose implementation details that other modules start depending on.
Imagine the network module exposes:
output "internal_subnet_resource_name" {
value = aws_subnet.internal.name
}and the compute module starts depending on that resource-specific detail.
Now changing the network implementation may require changing the compute module.
A stronger interface might expose:
output "subnet_ids" {
value = aws_subnet.internal[*].id
}The exact implementation can then change while preserving the meaning of the interface.
This is one reason module outputs should represent useful concepts, not simply every attribute available inside the module.
Good Module Interfaces
A good module interface usually has:
- a small number of meaningful inputs
- useful outputs
- clear names
- explicit types
- sensible defaults where appropriate
- validation where appropriate
- behavior that is predictable
- little leakage of internal implementation details
Imagine a module called network.
A useful interface might be:
Inputs:
name
network_cidr
subnet_cidrs
Outputs:
network_id
subnet_idsThe caller can understand that interface without reading the module’s entire implementation.
Bad Module Interfaces
A module can also become an abstraction that is technically reusable but painful to use.
For example:
enable_feature_a
enable_feature_b
enable_feature_c
resource_a_name
resource_a_size
resource_a_mode
resource_a_timeout
resource_a_option_1
resource_a_option_2
...Eventually the caller has to understand dozens of switches just to use the module.
That is often a sign that the module is exposing the underlying implementation rather than providing a useful abstraction.
Another problem is creating inputs solely because the module author does not want to make any decisions.
For example, if the module’s purpose clearly requires one sensible naming convention, exposing ten different naming switches may make the module harder to use rather than more flexible.
Note
Reuse does not mean “allow every possible configuration.” Reuse means that the same useful component can serve multiple callers through a sensible interface.
Module-To-Module Communication
A strong composition pattern is:
Root Module
β
ββββββββββββββ΄βββββββββββββ
βΌ βΌ
Network Module Storage Module
β β
β subnet IDs β storage ID
βΌ βΌ
Compute Module Application ModuleThe root module owns the relationships.
The child modules own their implementations.
This is often preferable to making child modules directly discover or manipulate each other.
For example, rather than making the compute module somehow know how the network module is implemented, the root module passes the required network output into the compute module.
That keeps responsibilities clear.
A Complete Composition Example
Here is a small conceptual structure:
infrastructure/
βββ main.tf
βββ modules/
βββ network/
β βββ main.tf
β βββ variables.tf
β βββ outputs.tf
βββ compute/
βββ main.tf
βββ variables.tf
βββ outputs.tfThe network module exposes:
output "subnet_ids" {
value = local.subnet_ids
}The compute module accepts:
variable "subnet_ids" {
type = list(string)
}The root module connects them:
module "network" {
source = "./modules/network"
}
module "compute" {
source = "./modules/compute"
subnet_ids = module.network.subnet_ids
}That single reference is doing several jobs:
- It passes information from one module to another.
- It expresses the architecture.
- It creates a dependency Terraform can understand.
- It keeps the implementation details of both modules behind their interfaces.
Think In Terms Of Contracts
A module input/output pair is effectively a contract.
The caller promises:
I will provide the inputs you require.
The module promises:
I will provide these outputs.
For example:
Network Module
βββββββββββββββββββββββ
name βββββΊβ β
cidr βββββΊβ implementation βββββΊ network_id
subnets ββΊβ βββββΊ subnet_ids
βββββββββββββββββββββββThe caller should primarily care about the left and right sides of this diagram.
The implementation in the middle can evolve.
That is the core of module composition.
The Design Test
When designing a module, ask:
What decisions belong to the caller?
What decisions belong inside the module?
What values will callers genuinely need?
What implementation details can remain private?If the answers are unclear, the module boundary probably needs more thought.
The goal is not to create the maximum number of inputs and outputs. The goal is to create an interface that communicates the module’s purpose clearly.
That is what turns a collection of Terraform resources into a reusable infrastructure component.