Step 01 of 06
Learn the concept
A module is not a folder you made because main.tf got long. It is an interface. Good modules make a decision once and expose the few choices callers should really have; bad modules forward thirty variables and call that abstraction.
The ideas this is made of
Variables are an API, not a form
Every variable you expose becomes something callers can set incorrectly and something you must support later. Types catch shape errors. Validation catches domain errors such as an empty environment name or an invalid CIDR. Defaults should encode policy only when the module truly owns that policy. Otherwise the default becomes a surprise production decision hiding in a reusable package.
Locals keep naming close to meaning
Locals are named expressions. They do not make values configurable; they make repeated or derived values readable. A local such as name_prefix = "beacon-${var.environment}" keeps resource names consistent without asking every caller to pass the same prefix. Use locals when a value is a consequence of inputs, not when it is a choice the caller owns.
Outputs are the narrow return path
A module output should expose the facts other code needs: a VPC ID, subnet IDs, a cluster endpoint, a security group ID. It should not expose every internal attribute because that freezes implementation details. The best output names describe intent rather than provider trivia. private_subnet_ids ages better than aws_subnet_a_id.
Module sources need versions
Local module paths are fine inside one repository. Shared modules should be pinned to a registry version or a Git tag. A branch source such as main makes every plan depend on whatever changed upstream since yesterday. Infrastructure review needs stable inputs. Version bumps should be explicit diffs, not surprise dependency updates during a Friday apply.
variable "environment" {
type = string
description = "Short environment name used in resource names."
validation {
condition = can(regex("^[a-z][a-z0-9-]{1,14}$", var.environment))
error_message = "Use 2-15 lowercase letters, digits or hyphens, starting with a letter."
}
}
locals {
name_prefix = "beacon-${var.environment}"
}
output "name_prefix" {
value = local.name_prefix
}
The module is almost comically small, which makes the API visible. The caller chooses an environment; the module owns how that becomes a prefix.
Good module interface or bad wrapper
| Signal | Good module | Bad wrapper |
|---|---|---|
Variables | Few, typed, validated | Every provider argument |
Outputs | Intent-shaped facts | Internal resource dump |
Policy | Encoded once | Caller must remember |
Versioning | Pinned release | Floating branch |
What these are called on the job
Root module — The Terraform configuration in the working directory where commands are run.
Child module — A module called by another module using a
moduleblock.Output — A named value returned by a module for humans or other modules to consume.
Validation block — A rule attached to an input variable that rejects invalid values before planning proceeds.
