Skip to content

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.name

Inputs 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_ids

This 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
  β–Ό
Caller

Modules Can Be Composed

Now consider two modules:

Network Module
      β”‚
      β”‚ subnet IDs
      β–Ό
Compute Module

The 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 Module

The 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 Layer

The 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_ids

The 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 Module

The 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.tf

The 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:

  1. It passes information from one module to another.
  2. It expresses the architecture.
  3. It creates a dependency Terraform can understand.
  4. 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.

Last updated on