Skip to content
intermediate Phase 7 · Module Design and the Module Registry

Designing Module Input and Output Contracts

Choosing stable variable names, sensible defaults, and meaningful exported values.

55m
0 problems
Topic Progress 0%

Every variable Block Is Public API: Trimming the Module Surface

A teammate opens a pull request adding three inputs to your network module: nat_eip_allocation_id, nat_gateway_subnet_index, and nat_gateway_count_override. The instinct is to merge — more flexibility. The better response is to ask which of those anyone will ever set. Every variable block you publish is public API: it must be named well, documented, validated, kept working through every future refactor, and never silently changed.

Take a module that has drifted into twenty inputs and audit each one:

Input Who sets it? Verdict
cidr Every caller, differently Public — required, no default
environment Every caller, from its own tfvars Public — defaulted to "dev"
enable_flow_logs Nobody, ever (checked 12 repos) Absorb into a local
az_count Nobody — always 3 Hard-code; keep the code, delete the input
nat_gateway_subnet_index One caller, once, as a workaround Fix the bug it worked around instead

What remains is the module's real interface. Declare each input the same way, every time:

variable "environment" {
  description = "Deployment environment; used for name prefixes and retention policy."
  type        = string
  default     = "dev"
}

variable "private_subnet_ids" {
  description = "Map of availability zone to private subnet ID for workload placement."
  type        = map(string)
}

Three habits make that declaration useful. Type it strictly — map(string) instead of "anything", so a list-of-strings mistake fails at plan instead of inside a for expression. Describe it in one sentence a stranger can act on; the description is what renders in registry docs and in terraform-docs output, and it is the only documentation most consumers will read. Default it whenever a sane default exists, so the common call shrinks:

module "labels" {
  source = "./modules/labels"
}

A zero-argument call means the defaults are doing their job. If your module cannot be called without eight arguments, the design decisions it should have made were pushed out to every consumer — and now they are duplicated, divergent, and untestable in one place.

For a module that has grown organically, the trimmed result usually looks like this — a handful of inputs where there were twenty, every one of them something a caller can legitimately want to change:

# The public surface after an audit: nothing a caller has never set
variable "cidr" {
  description = "IPv4 CIDR block for the VPC."
  type        = string
}

variable "environment" {
  description = "Deployment environment name."
  type        = string
  default     = "dev"
}

variable "az_count" {
  description = "How many availability zones to spread subnets across."
  type        = number
  default     = 3
}

Notice what disappeared: every input that merely exposed an internal implementation detail, every alias for a value derivable from the inputs that remain, every "just in case" toggle. The module got smaller and more valuable at the same time, because a caller now needs to understand a handful of decisions instead of twenty.

Now the hard part: change. Renaming subnets to subnet_ids reads like a cleanup to you and like a breaking change to the twelve repositories calling the module. Both halves of the break surface as errors at their next plan:

Error: Unsupported argument

An argument named "subnets" is not expected here.
Error: Required variable not set

The variable "subnet_ids" is required, but is not set.

The safe evolution path has three steps: add the new input with a clear name, read it with a fallback to the old one inside the child (local.subnet_ids = coalesce(var.subnet_ids, var.subnets)), document the old input as deprecated in its description, then remove it in the next major version. Renaming without that ladder is how a module earns a reputation — and reputation is what keeps teams copying your code instead of consuming Module Source Addresses with confidence.

Failing at Plan: validation Blocks, Sensitive Inputs, and Output Preconditions

A contract you do not enforce is a suggestion. Terraform gives you two places to enforce one module's rules, and they catch different classes of mistake.

** validation blocks check a single input against its own domain:**

variable "environment" {
  description = "Deployment environment."
  type        = string
  default     = "dev"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be one of: dev, staging, prod."
  }

  validation {
    condition     = length(var.environment) <= 16
    error_message = "environment must be 16 characters or fewer — it becomes a name prefix."
  }
}

Write the error_message as the fix, not the complaint. When the rule fires, Terraform prints your text verbatim and then points at the declaration:

Error: Invalid value for variable

environment must be one of: dev, staging, prod.

This was checked by the validation rule at modules/network/variables.tf line 12,
in variable "environment":

That trailer is the payoff: the message names your file and your rule, instead of an AWS error about an unsupported value that surfaces three resources into an apply. Multiple blocks are allowed, so cover the domain (allowed values), the shape (length, pattern, range), and keep each message actionable.

** precondition blocks check invariants that span inputs or outputs:**

output "replica_ids" {
  description = "IDs of the read replicas."
  value       = aws_db_instance.replica[*].id

  precondition {
    condition     = length(var.replica_ids) == 0 || var.multi_az
    error_message = "Replicas are only valid when multi_az = true on the primary."
  }
}

You cannot express "replicas require multi-az" as a rule about one variable, because each input is valid alone and only the combination is wrong. Preconditions run during plan too, so the invariant is checked before anything is created.

Enforcement point Catches Cost of getting it wrong
variable validation Value outside its own domain Fails at plan, message in your file
output/resource precondition Illegal combinations of values Fails at plan, message in your file
Provider or cloud API Everything else Fails mid-apply, message in someone else's docs

Two more contract tools belong here. sensitive = true masks a value in CLI output — terraform output prints (sensitive value) — while keeping it fully usable in expressions; remember it masks presentation only, the value still lives in state, so it never replaces encryption or secret management. nullable = false rejects an explicit null at plan instead of letting it travel into the module and detonate three resources deep. And because validation runs on input variables of the module you are writing, the child enforces its own rules regardless of which caller passes what — the enforcement travels with the code, not with the reviewer's checklist.

variable "db_password" {
  description = "Password for the primary database instance."
  type        = string
  sensitive   = true
  nullable    = false
}

That trio does three different jobs: type checks the shape, sensitive masks presentation everywhere the value is printed, and nullable stops an explicit null — usually a mis-wired module.database.password = var.maybe_password where maybe_password was never set — at plan time. Without it, null travels into the child, fails a provider argument validation deep inside the resource block, and the message you get back references the provider's schema instead of your wiring mistake. Every rule you move upstream turns an apply-time mystery into a plan-time sentence you wrote yourself.

Interviewers ask where you validate module inputs almost verbatim. The answer that lands: at the boundary, during plan, with messages written for the caller — then mention that the provider's own constraints are the backstop, not the front line.

Outputs Callers Wire Forward: What to Export, What to Keep Internal

Inputs are only half the contract. The other half is what the module hands back, because downstream modules and application code can only touch what you export.

# modules/network/outputs.tf  (provider side)
output "vpc_id" {
  description = "ID of the shared VPC."
  value       = aws_vpc.this.id
}

output "private_subnet_ids" {
  description = "Map of availability zone to private subnet ID."
  value       = { for s in aws_subnet.private : s.availability_zone => s.id }
}

output "database_password" {
  description = "Password for the primary database."
  value       = aws_db_instance.this.password
  sensitive   = true
}
# envs/prod/main.tf  (consumer side)
module "network" {
  source = "../../modules/network"
  cidr   = var.vpc_cidr
}

module "database" {
  source = "../../modules/database"

  vpc_id         = module.network.vpc_id
  subnet_ids     = values(module.network.private_subnet_ids)
  kms_key_arn    = module.kms.key_arn
}

Read the pair as one sentence: the database knows the network module only through two outputs. That is the entire sanctioned way modules in a configuration talk to each other, and it is what makes the graph correct — a value passed output-to-input creates the dependency Terraform orders by, with no depends_on and no guessing.

Shape the exports deliberately:

  • Export identifiers, ARNs, endpoints, and maps other code needs — things a consumer stitches into its own arguments.
  • Export derived convenience, like the AZ-keyed subnet map above. values() at the call site beats forcing every caller to zip lists together, and it survives a change of subnet count.
  • Do not export whole resource objects. output "vpc" { value = aws_vpc.this } hands callers the provider's entire schema; a provider upgrade then breaks consumers who read attributes you never intended to promise.
  • Describe and flag every output. description is the documentation; sensitive = true masks the value as (sensitive value) in CLI output — presentation only, since it is still stored in state and still readable by any expression.

Removing an output is as breaking as removing an input, and the error is immediate and specific:

Error: Unsupported attribute

This object does not have an attribute named "vpc_id".

Note what the CLI does and does not show you. terraform output lists the root module's outputs only — module outputs are consumed by configuration, never by the command line — and a sensitive root output appears masked:

$ terraform output
db_endpoint = "prod-db.internal:5432"
db_password = (sensitive value)

The masking stops casual shoulder-surfing and log-scraping; the real value is still sitting in state, which is a separate conversation about who can read the state backend.

Here is the change matrix worth memorizing for design reviews:

Change Breaking? Version bump
Add an input with a default No Minor
Add an output No Minor
Tighten validation so a previously legal value fails Yes Major
Rename or remove an input Yes Major
Rename, remove, or reshape an output (list → map) Yes Major

The reshape row is the subtle one: private_subnet_ids changing from a list to a map still type-checks at the call site in many cases and breaks only at apply — which is exactly why the contract deserves the same rigor as code signatures. Get the two surfaces right and the composition of many modules into one environment becomes mechanical: Composing Modules into Layers is where those surfaces start carrying real weight.

Quiz

1. You want to change a published module input from `subnets` (list) to `subnet_ids` (list) with a rename. What is the correct sequence?

Question 1 options

2. Which rule belongs in a `validation` block rather than a resource `precondition`?

Question 2 options

3. A consumer reads `module.network.vpc_id` after you deleted that output. What does plan report?

Question 3 options

4. What does `sensitive = true` on an output actually guarantee?

Question 4 options

Flashcards

Question

Why is a module `variable` block treated as public API?

Answer

Because callers bind to its name, type, and nullability: renaming it breaks every caller with `Unsupported argument` (and the new name with `Required variable not set`), tightening its validation rejects previously legal values, and its description is the only docs consumers read. Changes need the same semver discipline as code signatures.

Question

What does a sensible default accomplish that no default does not?

Answer

It lets the common call be zero-argument. Without defaults every consumer restates the same values (usually copy-pasted, then diverging); with them the module carries its own design decisions and overrides become the exception.

Question

When do you use a `validation` block versus an `output` precondition?

Answer

`validation` checks one input against its own domain (allowed values, length, pattern) and prints `Invalid value for variable` plus "This was checked by the validation rule at ...". Preconditions check relationships that span inputs or outputs (replicas require multi_az) — both fail during plan, in your file.

Question

What should a module export, and what should it refuse to export?

Answer

Export identifiers, ARNs, endpoints, and derived maps other modules wire forward — with descriptions, and `sensitive = true` where masked. Refuse to export whole resource objects: that promises callers the provider schema and breaks them on upgrades. Removing or reshaping an output yields `Unsupported attribute`.

Question

Classify: adding an input with a default, tightening validation, renaming an output, adding an output.

Answer

Adding an input with a default — non-breaking, minor. Adding an output — non-breaking, minor. Tightening validation — breaking, major (previously legal values now fail). Renaming an output — breaking, major (consumers get `Unsupported attribute`).

Revision Notes

Key Takeaways

  • 1. Every `variable` block is public API — names, types, and defaults get consumed by other repositories, so trimming the surface matters more than adding knobs
  • 2. Sensible defaults collapse the common call to zero arguments; forcing callers to restate the same values is deferred design work that diverges across repos
  • 3. Validation belongs at the boundary during plan: `validation` blocks for a single input, `precondition` for combinations, both printing your message and your file
  • 4. Outputs are the only sanctioned channel between modules — export identifiers and derived maps, never whole resource objects
  • 5. Breaking changes are rename, removal, reshape, and tightened validation; map them to a major version and migrate callers through a deprecation window first

Interview Tips

  • • "How do you evolve a module used by twenty repos?" — add the new input, fall back to the old internally, mark it deprecated in the description, remove at the next major; Terraform never rewrites callers for you
  • • "Where would you validate this input?" — a `validation` block with a fix-shaped `error_message`; then distinguish it from a precondition for cross-input invariants and from provider-side limits
  • • "What makes an output breaking to change?" — anything that alters name or shape: `Unsupported attribute` at the consumer's next plan; note that list→map can pass type checks and fail at apply
  • • "Does `sensitive` protect the value?" — no: it masks CLI presentation as `(sensitive value)`, while the value remains in state and readable by expressions; say what you use instead for real secrets

Cheat Sheet

Module Input/Output Contracts

Input declaration template:

variable "environment" {
  description = "Deployment environment; used for name prefixes and retention."
  type        = string
  default     = "dev"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be one of: dev, staging, prod."
  }
}

Output declaration template:

output "private_subnet_ids" {
  description = "Map of availability zone to private subnet ID."
  value       = { for s in aws_subnet.private : s.availability_zone => s.id }
}

Surface rules:

Do Don't
Publish only inputs callers genuinely customize Expose knobs nobody sets — absorb them into locals
Type strictly, describe in one actionable sentence Leave type loose or description empty
Default everything with a sane default Force 8+ arguments on every call
Export ids / ARNs / endpoints / derived maps Export whole resource objects
Add → fall back → deprecate → remove at major Rename a published input or output in place

Change matrix: add input (with default) = minor · add output = minor · tighten validation = major · rename/remove input = major · rename/remove/reshape output = major.

Enforcement: variable validation (single input domain) and precondition (cross-value invariants) both fail at plan with messages in your file; provider/cloud API errors are the backstop, mid-apply. sensitive = true masks CLI display only — value stays in state; nullable = false rejects explicit null at plan.

Continue Learning