Designing Reusable Modules
A reusable module is a product with users, and designing one well means thinking past your first use case: a clear minimal interface, sensible defaults, sound validation, useful outputs, documentation, and a versioning discipline. This page pulls the chapter together into the principles that separate a module others adopt happily from one they fork in frustration.
The test of a module is the second consumer. Anything works for the caller who wrote it; a module is reusable only when a different team, with a slightly different need, can adopt it without reading the source or forking a copy. Every principle here is aimed at that second consumer.
Interface Design
Design the interface around minimal required inputs, optional inputs with defaults for everything else, precise types, and validation. The required set is what the module genuinely cannot guess — a name, the subnets it lives in. Everything with a safe default becomes optional, so the common call is short. Precise types and validation blocks turn misuse into a clear plan-time error at the call site instead of a failure deep in the apply.
variable "instance_count" { type = number description = "Number of instances, 1 to 10." default = 2 validation { condition = var.instance_count >= 1 && var.instance_count <= 10 error_message = "instance_count must be between 1 and 10." } }
Sensible Defaults and Escape Hatches
Make the common case trivial and the advanced case possible. A good default — a db.t3.micro class, a 7-day backup retention, encryption on — means most callers write three lines and get a sound result. The escape hatch is the optional input that lets the rare caller override that default when they have a real reason. Defaults carry the eighty percent; escape hatches keep the other twenty from forking the module.
Documentation and Examples
A module nobody can use without reading its source is not reusable. Ship a README with input and output tables — generated by terraform-docs so they never drift from the code — and a runnable examples/ directory that shows the module called for real. The example is the fastest way for a new adopter to learn the interface, and it doubles as something you can actually plan and apply to prove the module works.
module "db" { source = "../../" identifier = "orders" subnet_ids = ["subnet-0a1b", "subnet-0c2d"] # every other input uses the module's defaults }
Versioning and Changelogs
Follow semantic versioning strictly so consumers can pin and upgrade with confidence. A breaking interface change — a removed input, a renamed output — is a major bump; a new optional input is a minor; a fix is a patch. Keep a changelog so an adopter raising a constraint knows what they are getting. This discipline is what makes a consumer's ~> 6.0 pin actually safe: it only holds if you never sneak a break into a minor release.
Testing Modules
A module others depend on deserves tests, so a refactor that breaks the interface fails in CI rather than in a consumer's production. Terraform's built-in terraform test framework and contract testing — asserting the module still produces the outputs its consumers rely on — are how you keep the interface stable across changes. Both are covered in depth in the testing chapter; the point here is that a reusable module's interface is a promise worth verifying.
- Designing the interface around exactly one caller's needs, so the second consumer with a slightly different requirement has to fork the module instead of configuring it.
- Shipping no examples or docs, forcing every adopter to read the source to learn which inputs exist and what they do.
- Breaking the interface — removing or renaming an input — in a minor version bump, surprising everyone who pinned with
~>and trusted the SemVer contract. - Over-parameterizing for hypothetical future needs, drowning the common three-line case in forty optional knobs nobody uses.
- Leaving inputs unvalidated, so misuse fails deep in the apply with a provider error instead of at the call site with a clear message.
- Design for at least the second use case: minimal required inputs, good defaults, and advanced overrides available as optional inputs.
- Generate and commit input and output docs with
terraform-docs, and provide a runnableexamples/directory. - Follow semantic versioning strictly so a consumer's
~>constraint stays safe and a minor bump never breaks them. - Validate inputs with
validationblocks so misuse fails at the call site with a message that states the constraint and a valid example. - Test the module so a change that breaks its interface fails in CI, not in a consumer's production stack.
Knowledge Check
What is the real test of whether a module is reusable?
- A second team with a slightly different need can adopt it without reading the source or forking a copy
- It exposes the maximum possible number of input variables so that it can cover every conceivable use case
- It is published to and sourced from the public Registry rather than from a local path
- It provisions more than ten distinct resources from a single module call
Why do examples and generated docs matter for a reusable module?
- They let an adopter learn the interface without reading the source, and stay in sync with the code when generated
- Terraform refuses to run apply on a module unless a README documentation file is present in the module's directory
- They replace the need for input variable validation blocks in the module
- They are a hard requirement for the module to be published to the Registry
How does strict SemVer discipline protect a module's consumers?
- It keeps a
~>pin safe — breaking changes only land in a major bump, so minor and patch updates never surprise a consumer - It guarantees that every published version is fully compatible with every other version ever released, in either direction across the line
- It lets consumers skip pinning a version constraint in their module call entirely
- It encrypts the module's declared outputs as they change across versions
What is the trade-off of over-parameterizing a module for hypothetical future needs?
- It drowns the common case in unused knobs, making the module harder to learn and easier to misconfigure
- Terraform caps a module at a fixed number of variables and will reject one that declares any more than that
- Each extra input adds its own AWS API call, slowing the plan measurably
- More inputs force consumers to pin the module to an exact version
You got correct