Designing and Evolving Modules
Scenario: A shared module's maintainer renames an internal resource for clarity, ships it as what they believe is a minor patch release, and every single consumer's next apply proposes destroying and recreating production resources - despite nothing about the real infrastructure needing to change.
By this point in the track you have all the individual pieces needed to write a genuinely production-quality reusable module: typed, validated, well-described variables (Module 4); precise, minimal outputs (this module, previous section); pinned version constraints for anything the module itself depends on; and clear documentation with a worked example, so a caller doesn't have to read the module's internals just to use it correctly. The piece that's easy to underestimate is evolution: a module used by more than one team is, functionally, an internal API, and APIs need backward-compatibility discipline the same way a shared code library does.
Within one major version, avoid breaking changes: don't remove a variable a caller might depend on without a deprecation period, and - this is the exact fix for the scenario above - use moved blocks (Module 5) whenever an internal resource address changes, so consumers' state follows the rename automatically instead of Terraform reading it as delete-then-create. Preconditions and postconditions (brief assertions like "this output must never be an empty string") let a module protect assumptions that a type constraint alone can't express, failing loudly and immediately rather than producing a confusing downstream failure.
Analogy: A reusable module shared across teams is an internal platform API in every meaningful sense. Its internal resource labels function like database primary keys: quietly changing one without a migration path doesn't just "clean up the code" - to Terraform, it looks exactly like deleting one row and inserting a brand-new, unrelated one.
A worked configuration
variable "environment" {
type = string
validation {
condition = contains(["dev", "stage", "prod"], var.environment)
error_message = "environment must be dev, stage, or prod."
}
}
Whenever you change a shared module, test both the fresh case (a brand-new caller using the new version from scratch) and the upgrade case (an existing caller moving from the previous released version). This distinction matters more than it might seem: a module that passes every test on a from-scratch deployment can still silently destroy a real consumer's production resources during what should have been a routine upgrade, if a resource address changed underneath without a corresponding moved block.
Common mistakes in production
- Renaming an internal resource inside a shared module and shipping it as a routine patch release without a
movedblock - every consumer's next plan proposes an unwanted destroy-and-recreate. - Only testing a module change against a fresh, from-scratch deployment and never against an existing consumer upgrading from the previous version - the upgrade path is where breaking changes actually surface.
Goal: Treat the plan as a change contract: understand every create, update, replacement, and destroy before approving it. Reinforce this lesson in the tf-moved-refactor Terraform lab. Open/labs/terraformand choose slugtf-moved-refactor; the lab runs real Terraform against the offline FakeCloud provider.