Chapter 6: Modules
Topic 36

Module Inputs and Outputs

InterfaceModules

A module communicates with the outside world through exactly two channels: input variables it accepts and outputs it exposes. Everything else — its resources, its locals, its data sources — is private. Designing those two surfaces well is most of what makes a module pleasant or painful to use.

The difference between a module people adopt and one they fight is rarely the resources inside; it is the interface. A tight, well-typed, documented surface of a handful of inputs reads like a contract. Forty loosely-typed knobs read like a trap, with a thousand ways to misconfigure and no obvious right call.

Data through a module
input variables in
module internals
outputs out

Inputs as the Public API

Input variables are the module's public API. Each one carries a type, a description, and — where there is a safe default — a default value. The type turns a whole class of misuse into a plan-time error at the call site instead of a confusing failure deep in apply. The description is the documentation a caller reads to know what the input means. Treat that interface as the module's real product; the resources are an implementation detail behind it.

modules/rds/variables.tf — a typed, documented interface
variable "identifier" {
  type        = string
  description = "Name for the RDS instance."
}

variable "instance_class" {
  type        = string
  description = "RDS instance class, e.g. db.t3.micro."
  default     = "db.t3.micro"
}

variable "allocated_storage" {
  type        = number
  description = "Storage in GB."
  default     = 20
}

Outputs as Return Values

Outputs are what the module promises to expose — the IDs, ARNs, and endpoints a caller needs to wire it into the rest of the system. They are the only way a parent reads anything from inside the module, which makes them a contract, not a convenience. Keep the set minimal: every output is something a consumer can come to depend on, so expose what they actually need and nothing internal.

modules/rds/outputs.tf — the minimal return surface
output "endpoint" {
  value       = aws_db_instance.this.endpoint
  description = "Connection endpoint for the database."
}

output "port" {
  value       = aws_db_instance.this.port
  description = "Port the database listens on."
}

Encapsulation

Whatever the module creates inside — the RDS instance, its subnet group, its parameter group, the locals that compute names — is invisible to callers except through the outputs. A caller cannot reach module.db.aws_db_instance.this; it can only read module.db.endpoint. That wall is what lets the module change its internals freely. Rename the resource, split it in two, add a parameter group — as long as the inputs and outputs hold, no caller notices.

The corollary is that anything you expose becomes part of the contract. An internal resource reference that leaks out — a caller who learned they could read some attribute — is a dependency you now have to preserve through every refactor. Encapsulation only holds if you are disciplined about what crosses the boundary.

Sensible Defaults

The split between required and optional inputs decides how the common case reads. A required input — no default — forces every caller to supply it; an optional one with a default lets the simplest use be a single line. Get this right and the everyday call is short and the advanced call is still possible. Get it wrong by making everything required, and the simplest use of the module needs twenty arguments.

Default the values that are genuinely environment-independent — a sensible instance class, a 20 GB starting volume, a 7-day backup retention. Require the values that have no safe default, like the name or the subnet IDs the module cannot guess. The result is a module whose common case is one line and whose escape hatches are there when you need them.

Interface Stability

Inputs and outputs are a contract, so changing them is a versioning event. Adding a new optional input with a default is safe — existing callers pass the same arguments and get the same result. Removing an input, renaming one, changing its type, or dropping an output is a breaking change: it makes a caller's existing code fail or behave differently, and under SemVer it belongs in a major version bump.

This is why a small, deliberate interface ages well. The fewer inputs and outputs you commit to, the fewer things you can break later. Add optional inputs over time as real use cases arrive; resist exposing internals you might have to keep forever.

Common Mistakes
  • Exposing forty inputs to make the module "configurable," producing an interface nobody can learn and a thousand ways to misuse it — a breadth of options that costs more than it buys.
  • Returning an internal resource reference as an output that callers come to depend on, then breaking every one of them on an internal refactor.
  • Leaving inputs typed as any, so a wrong shape fails deep in the apply instead of at the call site with a clear message.
  • Making every input required, so the simplest use of the module needs twenty arguments and the common case is unreadable.
  • Removing or renaming an input in a minor version bump, breaking every caller who pinned with ~> and expected only safe updates.
Best Practices
  • Keep the input surface as small as the use cases require, and add optional inputs with good defaults over time rather than up front.
  • Give every input a precise type and a description; treat the interface as the module's real product.
  • Expose only the outputs consumers actually need — IDs, ARNs, endpoints — and keep internals private.
  • Provide defaults for environment-independent values so the common module call is one short, readable block.
  • Add optional inputs only; reserve any removal, rename, or type change of an existing input for a major version bump.
Comparable tools CloudFormation nested-stack parameters and outputs Pulumi component resource inputs and outputs Ansible role variables and registered facts

Knowledge Check

Through which two channels does a module communicate with the outside world?

  • Input variables coming in and outputs going out — everything else is private
  • Its own state file and the provider block it declares for itself
  • Its internal resources and its data sources, both directly readable by the caller
  • Environment variables and the backend configuration block it defines

Why does typing an input precisely instead of leaving it as any matter?

  • A wrong shape fails at the call site at plan time instead of deep in the apply
  • Typed inputs are stored encrypted in the state file while untyped ones are stored in plaintext
  • Terraform refuses to publish a module to the Registry if it has any untyped inputs
  • Untyped inputs are not allowed to declare a default value

Which change to a module's interface is safe and which breaks callers?

  • Adding an optional input with a default is safe; removing or renaming an existing input breaks callers
  • Both changes are safe, because Terraform reconciles any interface change automatically for callers at plan time
  • Adding any new input breaks existing callers, while removing one is always safe
  • Neither change matters as long as the resources inside the module are unchanged

What is the trade-off of exposing forty inputs to make a module configurable for every case?

  • The interface becomes unlearnable and offers a thousand ways to misconfigure, costing more than the added options are worth
  • Terraform caps a module at a hard limit of sixteen variables, so a module that declares forty separate inputs will not plan at all
  • Each declared input adds a separate AWS API call, measurably slowing the plan
  • Many inputs force the module to be sourced from a git branch rather than the Registry

You got correct