# Terraform & Infrastructure as Code — Part 3: Modules & Reusable Infrastructure Design

> **Series:** Terraform & Infrastructure as Code (3 of 9)
> **Part 1:** `01-fundamentals-and-workflow.md` — Fundamentals, HCL & the Plan/Apply Workflow
> **Part 2:** `02-state-management-and-remote-backends.md` — State Management & Remote Backends
> **Part 3:** This file — Modules & Reusable Infrastructure Design
> **Part 4:** `04-workspaces-and-environments.md` — Workspaces, Environments & Multi-Account Patterns
> **Part 5:** `05-providers-data-sources-and-provisioners.md` — Providers, Data Sources & Provisioners Deep Dive
> **Part 6:** `06-drift-detection-import-and-refactoring.md` — Drift Detection, Import & Refactoring Existing Infrastructure
> **Part 7:** `07-testing-terraform.md` — Testing Terraform: Static Analysis, the Native Test Framework & Terratest
> **Part 8:** `08-cicd-for-terraform.md` — CI/CD for Terraform: Pipelines, Gates & GitOps for Infrastructure
> **Part 9:** `09-governance-cost-and-multi-cloud-at-scale.md` — Terraform at Team Scale: Governance, Cost & Multi-Cloud Patterns
> **Questions:** `questions.md`

Assumes you're comfortable with Part 1's resource addresses and `for_each`, and Part 2's state splitting —
modules are the other half of "state per service": state decides *where* configuration's tracked, modules
decide *how much of it you have to write twice*.

## Table of Contents

1. [What a Module Actually Is](#what-a-module-actually-is)
2. [The Standard Module File Layout](#the-standard-module-file-layout)
3. [Variables Are a Module's API — Design Them Like One](#variables-are-a-modules-api--design-them-like-one)
4. [Outputs Are a Module's Contract With Its Caller](#outputs-are-a-modules-contract-with-its-caller)
5. [Provider Inheritance — Implicit vs. Explicit](#provider-inheritance--implicit-vs-explicit)
6. [The for_each-Plus-Provider-Block Incompatibility](#the-for_each-plus-provider-block-incompatibility)
7. [Module Composition — Root Modules Calling Child Modules](#module-composition--root-modules-calling-child-modules)
8. [Nested Modules and the Resource Address Prefix](#nested-modules-and-the-resource-address-prefix)
9. [Where a Module's Source Actually Lives](#where-a-modules-source-actually-lives)
10. [Semantic Versioning and Pinning Strategy](#semantic-versioning-and-pinning-strategy)
11. [Publishing to a Registry — Naming and Release Requirements](#publishing-to-a-registry--naming-and-release-requirements)
12. [Cross-Variable Validation and Postconditions](#cross-variable-validation-and-postconditions)
13. [Drawing Module Boundaries — One Module vs. Many](#drawing-module-boundaries--one-module-vs-many)
14. [Composition Over Configuration — Avoiding the God-Module](#composition-over-configuration--avoiding-the-god-module)
15. [Testing a Module Before It Ships](#testing-a-module-before-it-ships)
16. [The Module Release Lifecycle](#the-module-release-lifecycle)
17. [When Not to Use a Module](#when-not-to-use-a-module)
18. [Worked Scenario: Extracting the Network Module From Copy-Pasted Code](#worked-scenario-extracting-the-network-module-from-copy-pasted-code)
19. [Worked Scenario: the for_each Plus Provider Alias Bug](#worked-scenario-the-for_each-plus-provider-alias-bug)
20. [Worked Scenario: a "Minor" Module Upgrade That Broke Production](#worked-scenario-a-minor-module-upgrade-that-broke-production)
21. [Developing Against an Unpublished Module Version](#developing-against-an-unpublished-module-version)
22. [Part 3 CLI Cheat Sheet](#part-3-cli-cheat-sheet)
23. [Common Mistakes and Interview Traps](#common-mistakes-and-interview-traps)
24. [Worked Practice Problems](#worked-practice-problems)
25. [Summary and What's Next](#summary-and-whats-next)

---

## What a Module Actually Is

**Every Terraform configuration is already a module — the directory you run `terraform apply` from is the
"root module," and any directory referenced via a `module` block is a "child module." There is no separate
mechanism or special syntax for "making a module" — you're organizing configuration you'd otherwise write
inline into its own reusable, parameterized unit.**

```hcl
module "network" {
  source = "./modules/network"

  vpc_cidr    = "10.0.0.0/16"
  environment = "prod"
}
```

Calling a module is structurally identical to calling a resource — a block with a local name, arguments
that map to the module's declared `variable`s, and (for `output`s) an attribute reference pattern
(`module.network.vpc_id`) that mirrors `aws_vpc.main.id`. This symmetry is deliberate: from the caller's
perspective, a well-designed module should feel like using a slightly more powerful resource type, not like
learning a second language.

```mermaid
flowchart TD
    Root["Root module<br/>(where you run apply)"] --> Net["module.network<br/>(child module)"]
    Root --> DB["module.database<br/>(child module)"]
    Net --> VPC["aws_vpc.main"]
    Net --> Sub["aws_subnet.private"]
    DB --> RDS["aws_db_instance.this"]
    DB --> Net2["References module.network's<br/>outputs for subnet placement"]

    classDef root fill:#f0e9fb,stroke:#6d43c0,color:#10161c
    classDef child fill:#e5f0fa,stroke:#1d6fb8,color:#10161c
    classDef leaf fill:#eaeef1,stroke:#c3ccd4,color:#10161c
    class Root root
    class Net,DB child
    class VPC,Sub,RDS,Net2 leaf
```

**This chapter's caption**: the root module owns the overall composition — which child modules exist, and how
their outputs feed each other's inputs — while each child module stays focused on one coherent piece of
infrastructure.

> [!NOTE]
> A module doesn't have its own state file by default — its resources live in whatever state the *calling*
> configuration is connected to, with addresses prefixed by `module.<name>.` (covered in depth below). This
> is a common early misconception: modules are a code-organization and reuse mechanism, not a state-boundary
> mechanism the way Part 2's state splitting is.

## The Standard Module File Layout

**A consistent file layout — not a Terraform requirement, but a strong ecosystem convention — makes every
module in an organization navigable the same way, regardless of who wrote it.**

```
modules/network/
├── main.tf          # Resource definitions (or split further for a complex module)
├── variables.tf      # Every input, each with a description and type
├── outputs.tf        # Every output, each with a description
├── versions.tf        # required_version and required_providers
├── locals.tf          # Computed values internal to this module
└── README.md          # Usage example, generated/kept in sync via terraform-docs
```

For a module complex enough that one `main.tf` becomes unwieldy, splitting by *purpose* (`networking.tf`,
`iam.tf`, `security-groups.tf`) beats splitting arbitrarily by resource count — a future reader should be
able to guess which file holds what from the filename alone. The convention of naming a module's single
primary resource `this` (`resource "aws_db_instance" "this"`) is worth adopting too: it keeps the resulting
address (`module.database.aws_db_instance.this`) readable without a redundant second name repeating what the
module itself is already called.

| File | Contains | Analogy |
|---|---|---|
| `variables.tf` | Every input, typed, described, validated where it matters | A function's parameter list |
| `main.tf` (or split) | The actual resources | A function's body |
| `outputs.tf` | Every value the caller might need | A function's return value |
| `versions.tf` | Provider/Terraform version constraints | A function's declared dependencies |
| `README.md` | Human-readable usage, kept in sync via `terraform-docs` (Part 1) | A function's docstring |

## Variables Are a Module's API — Design Them Like One

**Every `variable` block in a module is a public interface decision, not an implementation detail — treat
adding, removing, or changing the meaning of one exactly as seriously as changing a public function's
signature in a shared library.**

```hcl
variable "vpc_cidr" {
  description = "CIDR block for the VPC — must not overlap with any peered VPC's range."
  type        = string
  validation {
    condition     = can(cidrhost(var.vpc_cidr, 0))
    error_message = "vpc_cidr must be a valid CIDR block."
  }
}

variable "enable_nat_gateway" {
  description = "Provision a NAT gateway per AZ. Disable for a fully private, cost-sensitive dev environment."
  type        = bool
  default     = true
}
```

**Minimize required inputs, maximize sensible defaults.** A module requiring 20 mandatory variables is
almost always under-designed — most callers want the common case with a handful of overrides, not a
from-scratch specification every time. `enable_nat_gateway` above is a good example of the pattern: a
sensible production default (`true`), with an explicit, self-documenting override path for the one
legitimate case (a cheap dev environment) where the default is wrong.

> [!TIP]
> **Best practice**: every variable gets a `description`, even an internal one only your own team will ever
> call — beyond helping a human reader, it's the only source `terraform-docs` (Part 1) has to generate a
> module's reference documentation from. A variable with no description produces a README row with a blank
> explanation column, which is worse than no generated docs at all because it looks intentional.

## Outputs Are a Module's Contract With Its Caller

**Outputs deserve the same interface discipline as variables — and unlike variables, a *removed* or
*renamed* output fails silently at the caller's plan time (an "output not found" error), not with any
warning at the module's own point of change.**

```hcl
output "vpc_id" {
  description = "VPC ID — pass to any module that needs to launch resources into this network."
  value       = aws_vpc.main.id
}

output "private_subnet_ids" {
  description = "Private subnet IDs, one per AZ, in the same order as var.availability_zones."
  value       = [for s in aws_subnet.private : s.id]
}
```

Export whole, purposeful values rather than forcing every caller to reconstruct a derived value themselves —
`private_subnet_ids` as a ready-to-use list is a better interface than exposing raw subnet resources and
expecting every caller to write the same `for` expression independently. This is the same "rich output
object" guidance Part 2 gave for `terraform_remote_state` outputs; a module's outputs are read the same way
by its caller, so the same discipline applies.

> [!IMPORTANT]
> Treat a module's outputs as append-only in practice, the same way you'd treat a public API — adding a new
> output is always safe, but removing or renaming one is a breaking change for every caller reading it,
> whether that caller is a sibling `module` block in the same configuration or, per Part 2, another state
> entirely via `terraform_remote_state`. A module's own version number (covered later in this chapter) is
> exactly the mechanism that should bump on a change like this.

## Provider Inheritance — Implicit vs. Explicit

**Provider configuration works fundamentally differently for modules than variables and outputs do — a
child module cannot declare its own `provider` block (only a root module can) and gets its provider
configuration from its caller through one of exactly two mechanisms.**

```hcl
# In the root module:
provider "aws" {
  region = "us-east-1"
}

provider "aws" {
  alias  = "west"
  region = "us-west-2"
}

module "network" {
  source = "./modules/network"
  # Implicit inheritance: the default (non-aliased) "aws" provider
  # is automatically available inside this module — no wiring needed.
}

module "network_west" {
  source = "./modules/network"
  providers = {
    aws = aws.west   # Explicit: an aliased provider is NEVER inherited automatically.
  }
}
```

| Mechanism | When it applies | Explicit config needed? |
|---|---|---|
| Implicit inheritance | The module uses the caller's *default* (non-aliased) provider | No — automatic |
| Explicit `providers` map | The module needs an *aliased* provider configuration | Yes — always, no exceptions |

This split matters practically the moment a module needs to provision into more than one region or account
— the module itself stays region-agnostic (it never hardcodes `region = "us-west-2"` anywhere), and the
*caller* decides which concrete provider configuration to hand it via the `providers` map, keeping the
module fully reusable across however many regions/accounts the organization eventually needs.

## The for_each-Plus-Provider-Block Incompatibility

**A module containing its own `provider` block cannot be called with `count`, `for_each`, or `depends_on` —
a real, sharp-edged Terraform limitation that surfaces as a confusing error the first time someone hits it,
and a strong argument for the "child modules never declare providers" rule stated above.**

```hcl
# modules/region-stack/versions.tf — DON'T DO THIS in a child module
provider "aws" {
  region = var.region
}
```

```hcl
# Root module — this WILL fail
module "region_stack" {
  source   = "./modules/region-stack"
  for_each = toset(["us-east-1", "us-west-2"])
  region   = each.key
}
```

```
Error: Module does not support for_each
The module at module.region_stack is a legacy module which contains its own provider
configurations, and so calling it using for_each is not allowed.
```

The fix is exactly what the earlier sections already recommend: strip the `provider` block out of the child
module entirely, and drive multi-region calls from the **root** module instead — either multiple named
`module` blocks each passed a differently-aliased provider, or (in Terraform releases and OpenTofu that
support it) a `for_each`-driven set of provider configurations at the root. This is one of the clearest
cases in the whole language where "a module should never configure its own provider" isn't just a style
preference — it's the only way to keep `for_each`/`count` available for that module at all.

> [!WARNING]
> This restriction bites hardest on a module that was written early, before the team needed multi-region
> support, and worked fine as a single-region module with its own convenient `provider` block for months.
> Moving to `for_each` for genuine multi-region provisioning later forces a real refactor, not a config
> tweak — bake "child modules never contain a `provider` block" into your module review checklist from day
> one to avoid this exact rewrite.

## Module Composition — Root Modules Calling Child Modules

**A well-composed root module reads like an assembly instruction, not a wall of raw resources — its whole
job is wiring child modules' outputs into each other's inputs, with as little raw resource logic of its own
as the composition genuinely needs.**

```hcl
module "network" {
  source      = "./modules/network"
  vpc_cidr    = "10.0.0.0/16"
  environment = "prod"
}

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

  vpc_id             = module.network.vpc_id
  subnet_ids         = module.network.private_subnet_ids
  identifier         = "checkout-prod"
  instance_class     = "db.r6g.large"
}

module "checkout_eks_node_group" {
  source = "./modules/eks-node-group"

  cluster_name = module.eks.cluster_name
  subnet_ids   = module.network.private_subnet_ids
  labels       = { workload = "checkout" }
}
```

Reading top to bottom, this root module tells the whole provisioning story for `checkout-service`'s
infrastructure without needing to open any child module's internals — `network` provisions first (nothing
else depends on it), then `checkout_database` and `checkout_eks_node_group` both consume its subnet outputs
in parallel (Terraform's graph, per Part 1, figures this parallelism out automatically from the references).

> [!TIP]
> **Best practice**: keep the root module's own resource count near zero for anything beyond genuinely
> orchestration-only glue. A root module accumulating dozens of raw `resource` blocks alongside its module
> calls is a sign some of that logic belongs inside a dedicated module of its own instead.

## Nested Modules and the Resource Address Prefix

**Every resource inside a child module gets its address prefixed with `module.<name>.`, and this prefixing
nests indefinitely — a resource three modules deep has a three-segment prefix, which matters directly for
`terraform state mv` (Part 2) and for reading plan output on a deeply composed configuration.**

```
module.network.module.subnets.aws_subnet.private["us-east-1a"]
```

This example address says: the root module calls `module.network`, which itself calls a nested
`module.subnets`, which contains an `aws_subnet.private` resource keyed by AZ. Reading a plan for a deeply
nested configuration means reading these prefixes carefully — `1 to change` buried three modules deep in a
50-resource plan is easy to skim past if you're not used to parsing the full address.

> [!NOTE]
> There's no hard limit on nesting depth, but each additional layer makes plan output harder to scan and a
> module's true dependency chain harder to reason about. Two levels (root → module → occasionally a nested
> module inside that) covers the overwhelming majority of real designs; reaching for a third or fourth level
> habitually is usually a sign the module boundaries themselves need rethinking, per the next section.

## Where a Module's Source Actually Lives

Part 1 already covered `source` address forms for consuming a module (registry, git, local path) — the
practical question this chapter adds is **which form to use for which kind of module**, since the three
forms carry genuinely different tradeoffs for a growing organization.

| Source form | Update mechanism | Best for |
|---|---|---|
| `./modules/network` (local path) | Immediate — same commit as the caller | A module that only ever makes sense inside this one repo |
| `git::https://...?ref=v2.3.1` | Explicit `ref` bump, reviewed as a normal PR | An internal module shared across multiple repos, without publishing infrastructure |
| A private registry (HCP Terraform, or a self-hosted one) | `version` constraint, resolved automatically | An internal module published for genuinely org-wide reuse, with real semantic versioning |
| The public registry (`terraform-aws-modules/...`) | `version` constraint | A well-maintained community module for a common, non-differentiated need (a VPC, an EKS cluster skeleton) |

A local path is the right default for anything genuinely single-repo. The moment a module is consumed from
two or more repositories, a local path stops working, and the choice becomes git-ref-pinned vs. a real
registry — the registry option is worth the setup cost once more than a couple of teams depend on the same
module, purely because `version = "~> 2.3"` reads and enforces far more clearly in a PR diff than a
git commit SHA or tag ref does.

## Semantic Versioning and Pinning Strategy

**A module published for reuse should follow semantic versioning exactly the way a library does — a major
bump for any breaking interface change (a removed/renamed variable or output, a changed default that alters
real behavior), a minor bump for a backward-compatible addition, a patch bump for a fix with no interface
change at all.**

```hcl
module "network" {
  source  = "app.terraform.io/meridian-platform/network/aws"
  version = "~> 2.3"
}
```

The `~>` operator from Part 1 applies identically here — `~> 2.3` allows `2.3.x` and `2.4.x` but not `3.0.0`,
which is exactly the boundary a well-versioned module's major-version bump is supposed to signal as
"something here might require a code change on your end, read the changelog before upgrading." A module
publisher who doesn't take this discipline seriously — bumping only patch versions for changes that are
actually breaking — makes every consumer's `~>` pin meaningless, which is why the review-and-changelog habit
from Part 1's registry section applies with even more force to internal modules your own team owns.

> [!TIP]
> **Best practice**: for an internal module, write and maintain a `CHANGELOG.md` alongside version bumps —
> especially the "why" behind a major version, not just "what changed." A consumer deciding whether to
> upgrade needs to know if a breaking change affects *their specific usage*, which a diff alone often
> doesn't make obvious.

## Publishing to a Registry — Naming and Release Requirements

**Both the public Terraform Registry and HCP Terraform's private registry enforce a specific repository
naming convention and release process — getting this right the first time avoids a frustrating "why won't
this publish" cycle.**

Repository names must follow `terraform-<PROVIDER>-<NAME>` exactly — `terraform-aws-network`, not
`aws-network-module` or `network-terraform-aws` — where `<PROVIDER>` is the module's primary target
provider and `<NAME>` (which may itself contain hyphens) describes what it provisions. The module must
follow the standard file layout from earlier in this chapter (the registry's own documentation generator
depends on finding `variables.tf`/`outputs.tf` in the expected shape), and at least one semantically-versioned
release tag (`v1.0.0`, or `1.0.0` — the `v` prefix is optional but must be consistent) must exist before the
first publish.

```bash
git tag v1.0.0
git push origin v1.0.0
# Then, in the registry UI: "Upload" -> select the matching terraform-aws-network repository
```

| Requirement | Public registry | Private (HCP Terraform) registry |
|---|---|---|
| Repository naming | `terraform-<PROVIDER>-<NAME>`, mandatory | Same convention strongly recommended |
| Repository visibility | Must be public | Can be private |
| Release mechanism | Git tags matching semver | Git tags matching semver |
| Documentation | Auto-generated from `variables.tf`/`outputs.tf`/`README.md` | Same |

> [!TIP]
> **Best practice**: tag and publish a module's very first `v0.1.0` as soon as it has a working example, even
> before every planned feature lands — an early, real version number gives consumers something concrete to
> pin against immediately, rather than everyone temporarily pointing at an unpinned branch ref "until it's
> ready," which is exactly the kind of temporary arrangement that quietly becomes permanent.

## Cross-Variable Validation and Postconditions

**Terraform 1.9+ allows a `variable`'s `validation` block to reference *other* variables, not just itself —
closing a real gap where a module's inputs are individually valid but mutually inconsistent.**

```hcl
variable "min_size" {
  type = number
}

variable "max_size" {
  type = number
  validation {
    condition     = var.max_size >= var.min_size
    error_message = "max_size must be greater than or equal to min_size."
  }
}
```

Before 1.9, this kind of cross-variable check required a workaround — a `precondition` inside a `resource` or
`data` block's `lifecycle`, checked only at plan/apply time against that specific resource, not as a clean
upfront input check. Native cross-variable validation catches the mistake immediately, with a clear error
message, before Terraform even attempts to build a plan.

**Postconditions** solve a related but different problem — asserting something about a resource's state
*after* it's created, not about its inputs before:

```hcl
resource "aws_instance" "checkout" {
  # ...
  lifecycle {
    postcondition {
      condition     = self.public_ip == null
      error_message = "checkout instances must never receive a public IP — check subnet configuration."
    }
  }
}
```

| Mechanism | Runs when | Checks |
|---|---|---|
| `variable { validation {} }` | Before planning, against raw input values | "Are the inputs individually and mutually sane?" |
| `lifecycle { precondition {} }` | Before a resource/data block is evaluated | "Is it safe to even attempt this?" |
| `lifecycle { postcondition {} }` | After a resource is created/read | "Did the real result actually meet the contract I expect?" |

> [!TIP]
> **Best practice**: use a `postcondition` on any resource where a specific *outcome* matters more than the
> configuration that's supposed to produce it — the `checkout` instance example above catches a
> misconfigured subnet (missing the `map_public_ip_on_launch = false` setting from this chapter's earlier
> module-upgrade scenario) at apply time with a clear, specific error, instead of only being caught later by
> a security scanner or, worse, an actual exposure incident.

## Drawing Module Boundaries — One Module vs. Many

**The single most common module-design mistake is either one module per resource (too granular, all the
composition burden pushed onto every caller) or one giant module covering an entire environment (too coarse,
Part 2's monolithic-state problem reborn as a monolithic-module problem).**

```mermaid
quadrantChart
    title Module granularity tradeoff
    x-axis Too granular --> Too coarse
    y-axis Hard to compose --> Hides real structure
    quadrant-1 Coarse and opaque, worst of both
    quadrant-2 Fine-grained sprawl
    quadrant-3 The sweet spot
    quadrant-4 One giant do-everything module
    "One module per resource": [0.1, 0.55]
    "network, database, eks-node-group": [0.5, 0.15]
    "One module per bounded infra concern": [0.45, 0.2]
    "One module for the whole environment": [0.85, 0.85]
```

**A useful heuristic**: a module should represent one coherent, independently-deployable-in-concept piece of
infrastructure with a real name a platform engineer would say out loud — "the network module," "the
database module," "the EKS node group module" — not "the module that wraps `aws_instance`" (too granular)
and not "the checkout-service module" covering VPC-through-application-in-one-block (too coarse, and it
recreates exactly the blast-radius problem Part 2 spent a whole chapter fixing at the state layer).

| Signal | Suggests |
|---|---|
| A module has exactly one resource and no real logic around it | Too granular — inline it, or fold it into a sibling module |
| A module requires 15+ variables to configure | Possibly too coarse — look for a natural sub-boundary |
| Two different services need genuinely different configurations of "the same" module | The module's variable interface may need more `optional()` flexibility, not a fork |
| A module's README needs several paragraphs just to explain what it provisions | Likely too coarse — split along the boundary the explanation is already drawing |

## Composition Over Configuration — Avoiding the God-Module

**A module that grows a boolean flag for every possible variant ("enable_nat_gateway,"
"enable_vpc_endpoints," "enable_flow_logs," "use_ipv6," ...) eventually becomes harder to reason about than
several smaller, composed modules would have been — this is the infrastructure-module version of the
"god object" anti-pattern from application design.**

```hcl
# Anti-pattern: one module trying to be everything to everyone
module "network" {
  source                = "./modules/network"
  enable_nat_gateway     = true
  enable_vpc_endpoints   = true
  enable_flow_logs       = false
  enable_transit_gateway = true
  use_ipv6               = false
  # ... 12 more toggles
}
```

```hcl
# Composition: each concern is its own focused module, wired together explicitly
module "network" {
  source   = "./modules/network"
  vpc_cidr = "10.0.0.0/16"
}

module "vpc_endpoints" {
  source = "./modules/vpc-endpoints"
  vpc_id = module.network.vpc_id
}

module "flow_logs" {
  source = "./modules/flow-logs"
  vpc_id = module.network.vpc_id
}
```

The composed version costs a few more lines in the root module, and buys back something genuinely valuable:
each piece can be tested, versioned, and reasoned about independently, and a caller who doesn't need VPC
endpoints simply never calls that module — no dead, unused-but-still-evaluated toggle sitting in their
configuration.

> [!TIP]
> **Best practice**: reach for a boolean toggle inside a module for a genuinely minor variant (Part 1's
> `count = var.enable_feature ? 1 : 0` pattern for a single optional resource), and reach for composition —
> a separate, focused module — once a module accumulates enough toggles that reading its full variable list
> no longer tells a reader clearly what it actually does by default.

## Testing a Module Before It Ships

Part 7 goes deep on Terraform testing broadly; the module-specific version of that discipline worth
flagging here is that a module intended for reuse should ship with its own example configuration
(`examples/basic/`) that's also a real, runnable smoke test — not just documentation.

```
modules/network/
├── main.tf
├── variables.tf
├── outputs.tf
├── examples/
│   └── basic/
│       └── main.tf    # A minimal, real call to this module — doubles as living documentation
└── tests/
    └── network.tftest.hcl
```

`terraform test` (native, Terraform 1.6+) run from within the module directory against its own `tests/`
files is the mechanism that turns "does this module actually work" from a manual, pre-release ritual into
something CI runs on every PR, before a version is ever tagged and published for other teams to consume.

## The Module Release Lifecycle

**Pulling every practice in this chapter together, a healthy internal module's release process has a
consistent shape from first change to a consumer safely upgrading.**

```mermaid
stateDiagram-v2
    [*] --> Developed: Change made, tests run locally
    Developed --> Reviewed: PR opened, terraform test + tflint/checkov (Part 7) run in CI
    Reviewed --> Tagged: Merged, semver tag pushed per this chapter's rules
    Tagged --> Published: Registry (public or private) picks up the new tag
    Published --> Consumed: Consumers see the new version, read the changelog
    Consumed --> Upgraded: A consumer bumps their version constraint deliberately
    Upgraded --> Developed: Feedback/issues from real usage feed the next change

    note right of Tagged
        Version bump (major/minor/patch)
        decided HERE, per semver rules —
        not left to guesswork later
    end note

    classDef milestone fill:#e5f0fa,stroke:#1d6fb8,color:#10161c
    class Tagged,Published milestone
```

**This chapter's caption**: the version-bump decision happens once, at tag time, based on the actual nature
of the change — everything downstream (registry publish, consumer changelog review, the eventual upgrade)
depends on that one decision being made honestly, which is exactly why the earlier "read the changelog, not
just the version diff" guidance matters as much on the publishing side as the consuming side.

## When Not to Use a Module

**Not every repeated pattern justifies extraction into a module — a genuinely useful counter-question to
this whole chapter, worth asking before reaching for `module` out of habit.**

| Situation | Extract a module? | Why |
|---|---|---|
| The same 3-resource pattern appears in exactly 2 places, with no plan to grow beyond that | Usually no | The indirection cost (a caller now has to learn the module's interface) can exceed the savings for genuinely small, stable duplication |
| A pattern is copy-pasted across 3+ services and drifting (Part 3's own opening scenario) | Yes | Drift risk and duplicated-fix burden both grow with copy count — exactly the motivating case for this chapter |
| A resource's configuration is deeply specific to one service and unlikely to ever be reused | No | A module wrapping a single, non-reusable resource adds a layer of indirection with no real payoff |
| An external team needs to consume the same infrastructure pattern | Yes | A module is the only mechanism for sharing configuration across repository/team boundaries at all |

> [!NOTE]
> A small amount of deliberate duplication is a legitimate design choice, not automatically technical debt —
> the actual cost to weigh is "how often will this drift or need a coordinated fix across every copy" against
> "how much does learning and maintaining a shared module's interface cost." Two genuinely stable, unlikely-
> to-diverge copies of a 5-line resource block rarely justify the overhead a module adds.

## Worked Scenario: Extracting the Network Module From Copy-Pasted Code

Before this chapter's practices existed, `checkout-service`, `catalog-service`, and `inventory-service` each
had their own VPC provisioning code — three near-identical, independently-drifted copies of roughly the
same 80 lines, each with small, undocumented differences nobody could confidently explain (one had a
`enable_dns_hostnames` toggle the others lacked; nobody remembered why).

The extraction process: diff all three copies to find the actual variance (not the incidental
differences — different CIDR ranges, same otherwise), parameterize exactly that variance as module
variables, and default every genuinely-shared value so most future callers only need to set 2-3 arguments.
The `moved` block mechanism from Part 6 made the migration itself non-destructive — each service's existing
VPC resources were re-addressed under `module.network.aws_vpc.main` without a single resource being
destroyed and recreated, despite the underlying `.tf` files changing substantially.

```hcl
moved {
  from = aws_vpc.main
  to   = module.network.aws_vpc.main
}
```

> [!NOTE]
> This scenario is the concrete reason Part 6 exists as its own chapter — extracting a module from existing,
> already-applied resources is one of the single most common real-world reasons a team reaches for `moved`
> blocks, and getting it wrong (skipping `moved`, letting Terraform destroy-and-recreate three production
> VPCs during a "simple refactor") is exactly the kind of mistake this series is built to prevent.

## Worked Scenario: the for_each Plus Provider Alias Bug

A newer platform engineer, building the multi-region extension for chapter 9's disaster-recovery work,
copied an existing single-region `eks-node-group` module and added a `provider "aws" { region = var.region }`
block directly inside it — reasoning that this would let the module "just work" for whichever region it was
pointed at. The very next attempt to call it with `for_each` across two regions failed at `terraform plan`
with exactly the "does not support for_each" error from earlier in this chapter.

The fix followed the pattern already established: strip the `provider` block from the child module, move
provider configuration (two aliased `aws` providers, `aws.east` and `aws.west`) to the root module, and pass
the correct one explicitly via each `module` block's `providers` map. The engineer's instinct — "let the
module own its region" — was reasonable on its face, but it collided directly with a hard Terraform
constraint that only becomes visible the moment `for_each`/`count` enters the picture, which is exactly why
this chapter states the "no provider blocks in child modules" rule as a hard review-checklist item rather
than a soft suggestion.

## Worked Scenario: a "Minor" Module Upgrade That Broke Production

The team bumped `terraform-aws-modules/vpc/aws` from `~> 5.1` to `~> 5.4` in a routine dependency-update PR,
reviewed as a one-line version bump with no other changes. The module's own changelog, unread before merging,
noted that 5.3 had changed a previously-hardcoded default for `map_public_ip_on_launch` from `true` to
`false` on public subnets — a genuinely sensible security-hardening default for new users, and a silent
behavior change for anyone who had relied on the old default and never set the argument explicitly.

The resulting plan on the next apply showed a change to every public subnet's `map_public_ip_on_launch`
attribute — an in-place update, not a destroy, so it wasn't immediately alarming, but it would have quietly
broken auto-assigned public IPs for a public-facing load balancer's subnet if it had been applied without
review. The plan-review discipline from Part 1 caught it before `apply`; the actual process fix afterward
was mandating a changelog read (not just a version-number diff) for any public-registry module bump, exactly
the same "review the plan, not just the code diff" discipline Part 1 established for resource-level changes,
applied one layer up at the module-dependency level.

## Developing Against an Unpublished Module Version

**Before a module change is tagged and published, a consumer needs a way to test against the in-progress
version — pointing `source` at a local checkout temporarily is the standard technique, with one important
discipline around not letting the temporary override reach a real commit.**

```hcl
module "network" {
  # Temporarily testing an unreleased change — points at a local clone
  # of the module repo instead of the pinned registry version.
  source = "../terraform-aws-network"
  # source  = "app.terraform.io/meridian-platform/network/aws"
  # version = "~> 2.3"
}
```

Commenting out the registry `source`/`version` pair rather than deleting it keeps the eventual "switch back"
a one-line change instead of retyping the pin from memory. Some teams formalize this further with a
gitignored `override.tf` (Terraform natively supports `_override.tf` files that merge into and take
precedence over matching blocks in the main configuration) specifically for this kind of local, never-
committed source swap — worth adopting once "test against an unreleased module change" becomes a routine
part of a team's workflow rather than a rare exception.

> [!WARNING]
> Never commit a local-path `source` override for a module that's meant to consume a published, versioned
> release — it silently breaks reproducibility for every other teammate and for CI, both of which expect
> the pinned registry version to exist at their own checkout path (or, worse, not to exist at all on a CI
> runner that never cloned the module repository as a sibling directory).

## Part 3 CLI Cheat Sheet

| Command | Purpose |
|---|---|
| `terraform get -update` | Re-download module sources without a full `init` |
| `terraform state list \| grep module.network` | List every resource address inside one module |
| `terraform plan -target=module.network` | Scope a plan to one module (break-glass only, per Part 2) |
| `terraform-docs markdown table .` | Generate a module's input/output reference from its code |
| `terraform test` (run inside a module directory) | Run the module's own `tests/*.tftest.hcl` files |
| `terraform providers` | Confirm which providers a configuration (and its modules) actually require |

## Common Mistakes and Interview Traps

| Mistake | Why it's wrong | Correct approach |
|---|---|---|
| Putting a `provider` block inside a child module | Blocks `for_each`/`count`/`depends_on` on any call to that module | Configure providers only in the root module; pass aliased providers explicitly via `providers = {}` |
| Treating a module's outputs as free to rename | Any caller (a sibling module, or `terraform_remote_state`) reading it breaks silently at their own plan time | Treat outputs as append-only; version-bump (major) for any rename or removal |
| One module per single resource | Pushes all composition burden onto every caller, with no real reuse benefit | Group resources that are genuinely provisioned/versioned together into one focused module |
| A module accumulating a boolean toggle for every possible variant | Becomes a "god module" that's hard to reason about or test in isolation | Prefer composing several focused modules for genuinely distinct concerns |
| Bumping a public module's version without reading its changelog | A version bump can carry a silent default-behavior change, not just new features | Read the changelog, not just the version diff, before merging any module version bump |
| Nesting modules three or four levels deep out of habit | Plan output and dependency reasoning both get harder to follow with each layer | Reach for a third nesting level only when the boundary genuinely needs it, not by default |

## Worked Practice Problems

**Problem 1**: A child module contains a `provider "aws" { alias = "east" ... }` block, and its caller
tries `for_each = toset(["a", "b"])`. What happens, and what's the fix?

*Answer*: `terraform plan` fails with a "module does not support for_each" error — any `provider` block
inside a child module (aliased or not) makes that module incompatible with `for_each`, `count`, and
`depends_on`. The fix is removing the provider block from the child module entirely and configuring/aliasing
the provider in the root module instead, passing it into each call explicitly (or per-`for_each`-key, in
Terraform/OpenTofu versions supporting that) via the `providers` argument.

**Problem 2**: A module's `outputs.tf` removes an output named `subnet_ids` in the same PR that adds a
better-named `private_subnet_ids`, with no version bump and no changelog note. What breaks, for whom, and
when do they find out?

*Answer*: Every caller of this module currently referencing `module.network.subnet_ids` breaks — not at the
moment of this PR merging, but the next time *their own* configuration runs `terraform plan`, when Terraform
reports the referenced output no longer exists. This is exactly the "silent breaking change" risk the
chapter's outputs-as-contract section warns about: the failure surfaces far from its cause, at a delay, and
for someone who had no visibility into the module's own PR at all — which is why a major version bump and a
changelog entry (not just a rename in the diff) are the actual fix, giving consumers a signal to look for
before their next upgrade, rather than a surprise mid-`plan`.

**Problem 3**: A team has one 400-line module covering an entire environment's networking, database, and
application-tier infrastructure together, with 22 input variables. A new service needs the same networking
and database pattern but a completely different application-tier setup. What's the actual design problem,
and what's the fix?

*Answer*: The module conflates three genuinely distinct concerns (networking, database, application tier)
into one unit, forcing every caller to accept or override all 22 variables even when they only need two of
the three concerns. The fix is splitting it into three focused modules — `network`, `database`, and
`application-tier` — composed together in the root module for services that want all three, while the new
service composes only `network` and `database` and builds its own application-tier configuration
independently. This is the "drawing module boundaries" heuristic from earlier in the chapter applied
directly: a coarse module that different callers only partially want is a strong, concrete signal it should
be split along the boundary the differing needs are already drawing.

**Problem 4**: A module's `variables.tf` declares `min_size` and `max_size` for an autoscaling group, with
no relationship enforced between them. A caller accidentally sets `max_size = 2` and `min_size = 5`. What
happens without cross-variable validation, and how does adding it change the failure mode?

*Answer*: Without validation, both values pass individually (each is a valid `number`), and the module
proceeds to create an `aws_autoscaling_group` with an internally inconsistent configuration — AWS itself
will likely reject the API call or produce confusing scaling behavior, and the resulting error surfaces deep
in a provider-level failure with a stack trace that doesn't obviously point back to "these two numbers don't
make sense together." A `validation` block cross-referencing `var.max_size >= var.min_size` on the `max_size`
variable (per this chapter's cross-variable validation section) catches this immediately, at `terraform plan`
time, with a clear, module-authored error message pointing at the actual mistake — turning a confusing
provider-level failure into an obvious, immediate one.

## Summary and What's Next

A module is nothing more than a directory of configuration with a parameterized interface — the discipline
that makes modules valuable is treating that interface (variables in, outputs out) with the same rigor as a
shared library's public API: minimal required inputs, sensible defaults, append-only outputs, and real
semantic versioning enforced by actually reading changelogs, not just version numbers. Provider configuration
is the one place modules genuinely can't be self-contained — child modules never own a `provider` block,
both because it's cleaner design and because Terraform enforces it the moment `for_each` enters the picture.
Drawing the right boundary (one coherent concern per module, composed rather than configured into a single
god-module) is the design judgment call every other practice in this chapter serves.

Part 4 builds directly on this chapter's composition patterns to answer a related but distinct question: not
how configuration is *organized* (modules), but how the *same* configuration gets deployed safely into
multiple environments and accounts — workspaces, directory-per-environment layouts, and the multi-account
patterns that keep a mistake in staging from ever having a path to reach production. Every module this
chapter taught you to design will, by the end of that chapter, be called from several different directories
at once — the interface discipline built here is what makes that reuse safe rather than fragile, not an
afterthought bolted on once the module already has real callers depending on it.
