Design Reusable Modules with Clear Contracts

A reusable Terraform module is a packaged contract, not just a folder of copied resources. The contract says what callers may provide, what the module promises to create or compute, which outputs other configurations may depend on, and what kinds of change are considered compatible. Good modules let teams repeat infrastructure patterns without repeating low-level decisions, while still making the important choices visible at the call site.

In this lesson, you will design modules by treating variables, outputs, provider requirements, resource addresses, validations, and documentation as one public interface. That matters in this Terraform course because module engineering sits between single-stack authoring and platform-scale infrastructure as code. A module with unclear inputs can make dozens of workspaces unsafe at once; a module with a narrow, tested contract can encode naming, tagging, network, or security rules consistently.

Purpose and Outcome

The purpose of a module contract is to separate stable intent from replaceable implementation. A caller should be able to say, for example, "create a production storage bucket for the billing service with these extra tags" without knowing every lifecycle rule, encryption setting, or naming rule inside the module. The module author remains free to improve internals when the public behavior is unchanged.

By the end, you should be able to decide what belongs in a module interface, write typed variables with validation, expose useful outputs without leaking implementation details, version a module safely, and troubleshoot failures caused by broken contracts.

How Terraform Modules Work Internally

When Terraform loads configuration, every module block expands into a separate module instance in the configuration graph. The root module is the directory where you run Terraform. Child modules are loaded from local paths, registries, Git sources, or other supported sources. Terraform evaluates input expressions in the caller, passes resulting values into the child module’s variables, evaluates resources and outputs inside the child module, and then makes selected outputs available to the caller as module.name.output_name.

Module boundaries affect addresses. A resource named aws_s3_bucket.this inside a child module called audit_bucket has a state address like module.audit_bucket.aws_s3_bucket.this. If the module later renames that resource to aws_s3_bucket.bucket, Terraform sees a different address unless you provide a moved block. This is why internal names are not completely private: state addresses are part of upgrade behavior, even if callers do not reference them directly.

Terraform also evaluates provider configuration through module boundaries. A child module can declare required_providers, but it should normally receive provider configurations from the root module. This keeps credentials, regions, aliases, and authentication decisions at the composition layer. A reusable module should declare the provider source and compatible version range it expects, but it should avoid hiding credentials or provider aliases inside itself.

Contract Anatomy

A Terraform module contract has five visible parts. First, variables define accepted inputs with types, defaults, nullable behavior, descriptions, sensitivity, and validation rules. Second, outputs expose stable values that callers may consume. Third, provider requirements state which provider plugins and features the module expects. Fourth, resource addressing and upgrade metadata define whether existing state can move safely. Fifth, documentation explains intent, examples, constraints, and migration notes.

Use precise types. Prefer object, map(string), set(string), and list(object(...)) over any when the shape is known. Use validation to catch invalid environment names, missing prefixes, impossible retention periods, or inconsistent options before provider calls begin. Use outputs sparingly: output values that downstream modules need, such as IDs, ARNs, names, endpoints, or policy documents. Do not output whole resources as a convenience, because that turns internal implementation details into a permanent interface.

Example 1: A Naming Contract

This first module has no provider and creates no remote objects. It standardizes names and tags, which makes it useful for testing contract decisions without cloud credentials.

variable "workload" {
  type        = string
  description = "Short service or application name."

  validation {
    condition     = can(regex("^[a-z][a-z0-9-]{1,30}$", var.workload))
    error_message = "workload must start with a lowercase letter and contain only lowercase letters, numbers, and hyphens."
  }
}

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

  validation {
    condition     = contains(["dev", "stage", "prod"], var.environment)
    error_message = "environment must be dev, stage, or prod."
  }
}

variable "extra_tags" {
  type        = map(string)
  description = "Additional non-authoritative tags supplied by the caller."
  default     = {}
}

locals {
  name = "${var.environment}-${var.workload}"
  tags = merge(var.extra_tags, {
    Environment = var.environment
    Workload    = var.workload
    ManagedBy   = "Terraform"
  })
}

output "name" {
  description = "Standard resource name prefix."
  value       = local.name
}

output "tags" {
  description = "Merged standard tags."
  value       = local.tags
}

If a caller sets environment = "prod" and workload = "billing-api", the deterministic output name is prod-billing-api. If the caller passes Environment = "test" in extra_tags, the module overwrites it with the authoritative value because the merge call places required tags last. That is a design choice: callers may add tags, but they may not redefine ownership tags.

Example 2: Calling the Module

A caller should show intent clearly. The module source, version, and arguments are part of the consumer’s side of the contract.

module "billing_names" {
  source = "./modules/naming"

  workload    = "billing-api"
  environment = "prod"
  extra_tags = {
    CostCenter = "finops"
    Owner      = "payments-platform"
  }
}

output "bucket_name" {
  value = "${module.billing_names.name}-events"
}

output "effective_tags" {
  value = module.billing_names.tags
}

The expected bucket_name value is prod-billing-api-events. The expected tags include CostCenter, Owner, Environment, Workload, and ManagedBy. The caller does not know how the module builds those values beyond the documented output behavior. If the module later changes its internal local names while preserving the same outputs, callers do not need to change.

Example 3: Testing the Contract

Terraform tests can exercise module behavior before it reaches a workspace. The test below checks both a successful contract and a rejected input.

run "valid_name_contract" {
  command = plan

  variables {
    workload    = "billing-api"
    environment = "stage"
    extra_tags = {
      Owner = "payments-platform"
    }
  }

  assert {
    condition     = output.name == "stage-billing-api"
    error_message = "module did not produce the expected standard name."
  }

  assert {
    condition     = output.tags.ManagedBy == "Terraform"
    error_message = "module did not preserve the required ManagedBy tag."
  }
}

run "reject_invalid_environment" {
  command = plan

  variables {
    workload    = "billing-api"
    environment = "production"
  }

  expect_failures = [
    var.environment
  ]
}

The first run should produce a valid plan and pass both assertions. The second run should fail during variable validation, before any provider operation could create or modify infrastructure. That is the point of a clear contract: invalid caller intent is refused near the boundary.

Design Choices and Trade-offs

Start with the smallest useful interface. A module that exposes every underlying provider argument is only a wrapper, and wrappers often add upgrade pain without reducing complexity. A module that exposes too little becomes rigid and forces teams to fork it. The practical middle ground is to expose decisions callers genuinely own, such as environment, workload, capacity tier, allowed CIDRs, or retention days, while keeping mandated standards inside the module.

Defaults are powerful but risky. A default for extra_tags is harmless because an empty map is meaningful. A default for environment may be dangerous because accidentally creating development infrastructure when production was intended is still wrong. Prefer no default when a value represents business intent or isolation boundaries.

Outputs are promises. If another workspace reads module.network.private_subnet_ids, changing that output’s type from list(string) to set(string) can break callers even if the infrastructure is unchanged. Name outputs after their meaning, not their current implementation. For example, private_subnet_ids is more stable than aws_subnet_ids.

Versioning should match compatibility. Patch releases should fix internals without changing required inputs, output types, or replacement behavior. Minor releases may add optional inputs or outputs. Major releases are appropriate when callers must change arguments, state must move, or resources may be replaced. In registry modules, publish examples and a changelog with migration steps. For Git-sourced modules, pin immutable tags rather than tracking a branch in production.

Failure Modes and Troubleshooting

Symptom: Terraform says a variable value is unsuitable. Cause: the caller provided a value that does not match the declared type or validation rule. Diagnose: run terraform validate and inspect the variable name in the error. Correct: fix the caller value, or change the module validation only if the original contract was too narrow.

Symptom: a module upgrade plans to destroy and recreate resources. Cause: an internal resource address changed, a key in for_each changed, or a provider argument forces replacement. Diagnose: inspect the plan addresses and compare the old and new module versions. Correct: add moved blocks for address changes, preserve stable keys, or document the replacement as a major upgrade requiring maintenance planning.

Symptom: downstream configurations fail after an output change. Cause: the output name, type, sensitivity, or meaning changed. Diagnose: search callers for module.<name>.<output> and compare expected types. Correct: restore the old output, add a new output for the new shape, and deprecate the old one over a planned compatibility window.

Symptom: a child module cannot use the intended cloud region or account. Cause: provider aliases were not passed from the root module, or the child module embedded provider configuration. Diagnose: inspect provider inheritance and any providers map in the module block. Correct: keep provider configuration in the root and pass aliases explicitly where needed.

Security, Reliability, and Performance

Module contracts affect security because they decide which permissions, networks, identities, and policy settings are configurable. Do not let callers disable encryption, public access protections, or required logging through a casual boolean unless that exception has a real review path. Mark sensitive outputs as sensitive = true, but remember that sensitive values can still live in state.

Reliability depends on stable resource identity. Use predictable for_each keys derived from durable names, not list indexes that shift when an item is inserted. Performance is usually about graph size and provider API behavior: a module that creates hundreds of resources behind one simple input may make plans slower and failures harder to isolate. Split modules by lifecycle and ownership when different resources are changed by different teams or at different frequencies.

Hands-on Lab

Prerequisites: Terraform CLI, an empty working directory, and no cloud credentials. This lab uses only variables, locals, outputs, and tests.

  1. Create modules/naming/main.tf with the code from Example 1.
  2. Create main.tf in the root directory with the code from Example 2.
  3. Create modules/naming/naming.tftest.hcl with the code from Example 3.
  4. Run terraform fmt -recursive to normalize formatting.
  5. Run terraform -chdir=modules/naming test. Verification succeeds when valid_name_contract passes and reject_invalid_environment is reported as an expected failure.
  6. Run terraform init and terraform plan in the root directory. Verification succeeds when outputs show prod-billing-api-events and merged tags.
  7. Change environment to production in the root caller and run terraform plan again. Verification succeeds when Terraform rejects the value before planning outputs.
  8. Cleanup by deleting the lab directory. No remote objects were created, so no destroy step is required.

Assessment Exercises

  1. You are designing a database module. Which inputs should be required, which should have defaults, and which provider settings should stay outside the module?
  2. A module currently outputs an entire resource object. Propose a safer output contract and explain what caller behavior might break during migration.
  3. A module upgrade changes for_each keys from subnet names to CIDR blocks. Predict the plan impact and describe a safer migration path.
  4. Write a validation rule for an input named retention_days that allows only 30, 90, or 365 days. Explain why this belongs at the module boundary.
  5. Review a module you use today and identify one value that is over-configurable and one value that is under-configurable.

Summary

Reusable Terraform modules work best when their contract is explicit and small. Typed variables define what callers may ask for, validation rejects invalid intent early, outputs expose only stable facts, provider requirements describe compatibility, and versioning tells callers what kind of change to expect. Treat module design as API design for infrastructure state: once many workspaces depend on it, every input, output, address, and default becomes part of the operational contract.