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

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/terraform and choose slug tf-moved-refactor; the lab runs real Terraform against the offline FakeCloud provider.