Terraform modules: input contracts, outputs and provider ownership
Build Terraform modules around a stable infrastructure capability. Design typed inputs, useful outputs and explicit provider ownership without hiding destructive behavior behind defaults.
TL;DR: A Terraform module should expose a clear infrastructure contract: what the caller must provide, what it may vary and what it receives. Keep environment credentials and provider configurations under root-module control, and review upgrades through their actual plans.
A module is a configuration boundary
A module groups resources and expressions behind input variables and outputs. The root module assembles the deployment; child modules provide reusable capabilities. A module call is not an isolated transaction or independent state backend. Resources from the call participate in the root configuration's graph and state unless the architecture deliberately uses separate configurations.
HashiCorp's module guidance recommends meaningful abstractions. A useful module might represent an application database with its approved access and observability configuration. A wrapper that renames every provider argument can add maintenance without establishing a useful contract.
Expose decisions callers genuinely own
Use typed inputs, explicit defaults and validation where appropriate. Document units and permitted ranges. An input named timeout leaves unanswered whether it is milliseconds or seconds, while an unbounded list of arbitrary policy statements can defeat a module intended to enforce a security standard.
Outputs should expose stable information callers need, such as a resource identifier or connection endpoint. Avoid requiring consumers to depend on internal resource names. Marking an output sensitive affects presentation; it does not, by itself, remove sensitive data from state.
The boundary expresses ownership. It should let a reviewer identify which decisions remain with the caller and which are guaranteed by the module.
Worked interface: a shared database module
Suppose four teams currently copy a database configuration. They need different capacity classes and backup retention, but all must use approved private networking. A module can accept the network identifier and capacity choice while enforcing the intended connectivity configuration and validating backup limits.
Keep environment-specific provider authentication in the root. A reusable child should declare its provider requirements and any aliases it expects, while the caller supplies the appropriate configurations. HashiCorp's providers-within-modules reference explains inheritance, explicit mappings and alias requirements.
Now imagine adding a new variable that defaults to replacing the storage configuration. Existing callers omit the variable and receive a destructive plan during an apparently routine upgrade. A syntactically optional input has changed the operational contract. Prefer a compatible default, an explicit migration option or a clearly signaled breaking release with a tested transition.
| Interface choice | Review question | Example consequence |
|---|---|---|
| Typed capacity input | Is the unit and supported range clear? | Invalid values fail before an API request |
| Private-network input | Can callers bypass the intended boundary? | Security promise remains meaningful |
| Output identifier | Can consumers avoid internal addresses? | Internal refactoring becomes less disruptive |
| Provider mapping | Which account and region receive changes? | A test configuration cannot silently target production |
| Default change | What happens to callers who omit it? | An upgrade can otherwise replace resources unexpectedly |
Test the contract, including upgrades
A module test should cover its meaningful guarantees: invalid inputs, required network settings, expected outputs and important combinations. Also inspect a plan from the previous release's state against the new module version. A clean fresh deployment cannot reveal every destructive upgrade.
Version-pin module sources using the mechanism supported by that source type. The provider dependency lock file is not a universal lock for all module versions. Record the selected module release and provider changes separately so reviewers can attribute plan differences.
Avoid a module with dozens of unrelated switches that produces entirely different architectures. When callers need different ownership, lifecycle or permissions, separate modules or configurations may be clearer. Excessively fragmented modules can also hide the graph, so use plan/apply architecture to explain the resulting dependencies.
Refactoring still affects resource identity
Moving a resource into a child module changes its Terraform address. Preserve its association with the existing remote object through a reviewed resource-address refactor. Renaming a module directory is not sufficient evidence that Terraform will retain the object.
Does a child module get its own state automatically? No. Its resources normally live in the root configuration's state under module-qualified addresses.
What should a reviewer inspect when upgrading a module? The release contract, input/default changes, provider changes and the target environment's plan, especially replacements and privilege changes.
Should every resource have its own wrapper module? Only if that wrapper establishes a useful supported capability or policy. Reuse alone does not justify an extra interface that simply mirrors the provider.
Terraform tests and policy checks turns those contracts into plan assertions, distinguishes mock evidence from real-provider behavior and includes a local failure experiment.