Terraform Module Design: Interfaces, Composition, and Versioning

Modules are where Terraform stops being scripting and starts being software engineering, and they fail the same way libraries fail: bad interfaces, hidden behavior, and versioning chaos. Today is design principles from modules that survived years of consumers.

The Interface Is the Product

A module’s variables and outputs are its API, and API rules apply. Accept IDs, not names, for dependencies (the subnet_id, not a name to look up, keeping the module free of data source coupling and letting the caller wire the graph). Give every variable a type, a description, and where possible a validation block that fails at plan with a human error instead of at apply with an Azure one. Default aggressively toward the secure and sensible, the hardening flags from this series (public access off, TLS minimums, diagnostics on) should be the defaults consumers must consciously override, because module defaults are policy that runs before Azure Policy does. And output everything a consumer plausibly needs, IDs, principal IDs of created identities, FQDNs, because adding an output later is a release, and consumers blocked on missing outputs fork modules, which is the beginning of the end.

variable "subnet_id" {
  type        = string
  description = "Resource ID of the delegated subnet for the server."

  validation {
    condition     = can(regex("/subnets/", var.subnet_id))
    error_message = "subnet_id must be a full subnet resource ID."
  }
}

variable "zone_redundant" {
  type        = bool
  description = "Deploy zone redundant HA. Disable only for dev."
  default     = true
}

Composition Over Configuration

The failed module pattern is the kitchen sink: one module deploying the app service, its database, the Key Vault, the networking, driven by forty booleans. It couples unrelated lifecycles, and every consumer’s special case becomes another flag until the module is an inner platform. Build small modules with one job (a hardened storage account, a PostgreSQL flexible server, a private endpoint set) and compose them in root modules or thin wrapper modules per application archetype. The test: if a variable exists only to skip half the module, the module is two modules. Resist premature abstraction in the other direction too, a module wrapping a single resource with passthrough variables adds a versioning layer and nothing else; the module boundary earns its cost when it encodes real decisions, naming, diagnostics, network posture, the things you want identical everywhere. For organizations that would rather adopt than build, the Azure Verified Modules library is the reference implementation of these principles and a legitimate foundation to standardize on, with your wrappers adding organizational opinion on top.

Versioning and Release Discipline

Modules live in their own repos (or a well tooled monorepo) and release with semantic versions: patch for fixes, minor for additive, major for breaking, where breaking includes anything that forces resource replacement, a renamed variable is annoying but a change that destroys databases is the definition of major. Consumers pin versions, pessimistic constraints on minor for workloads, exact pins for the landing zone, and never track main. Publish through the pattern from the DevOps posts: tags consumed via Git source references or a private registry, a changelog humans can read, and deprecation windows where old majors still receive security relevant fixes while consumers migrate, with moved blocks shipped inside the module smoothing renames for them. The maturity signal to aim for: upgrading your most used module’s major version across the estate is a scheduled chore, not a project with a war room. Tomorrow completes the software engineering turn: testing and policy scanning for all of this.

Cheers
Osama

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.