Terraform Professional (002): Refactoring into Modules and the Module Contract
A Terraform module contract is the set of things callers depend on — input variables, outputs, provider requirements and the resource addresses in their state — and refactoring into a module means moving resources behind that contract without breaking callers or recreating infrastructure. This lesson covers objectives 6a and 6b of the Terraform Infrastructure Engineer Professional (002) exam: when a configuration should become a module, how to scope and compose modules, how to move existing resources with moved blocks, how to design inputs, outputs and provider requirements, and which changes break callers.
On this page10 sections
- When should you refactor a configuration into a module?
- How do you decide the scope of a module?
- What are dependency inversion and data-only modules?
- How do you move existing resources into a module without recreating them?
- What is the contract of a Terraform module?
- How should a module's inputs be designed?
- How do outputs act as guarantees?
- How should a module declare providers?
- Which module changes break callers, and how do you deprecate safely?
- What is the standard module structure?
- Decide when repetition, shared standards or reuse justify a module, and when a thin wrapper should not be written
- Scope modules by encapsulation, privileges and volatility, and compose small modules with dependency inversion and data-only modules
- Move existing resources into modules with moved blocks placed in the right module and with the right instance keys
- Describe a module's contract and design typed, described inputs and narrow outputs that act as guarantees
- Declare provider requirements correctly in a reusable module and explain why provider blocks do not belong there
- Classify module changes as breaking or compatible, and deprecate inputs and outputs with Terraform 1.15
- Lay out a module using HashiCorp's standard module structure
When should you refactor a configuration into a module?
Refactor into a module when a group of resources is used together, repeated, and worth hiding behind a smaller interface. The usual triggers are: the same set of resources copied into several configurations (so every fix has to be made several times), organisational standards that every team must get right (encryption, logging, tagging, public-access blocks), and a group of resources that forms one higher-level concept such as "a load-balanced web tier" or "a queue with its dead-letter queue and alarm".
A module earns its place by raising the level of abstraction. Callers set a few meaningful inputs and read a few outputs; the module decides everything else. A standard that is fixed inside the module cannot be overridden by a caller, because callers can only change what the module exposes as an input. Exposing every resource argument "with secure defaults" looks flexible but hands the standard back to every caller.
Local module or versioned module?
| Need | Where the module lives | Why |
|---|---|---|
| Remove repetition inside one repository | Local path, e.g. ./modules/queue-set | Same repository, changes ship with the root that uses it |
| Reuse across several root configurations or teams | Its own repository with release tags, or a private registry | Callers reference a release, not a copy |
| Each consumer upgrades on its own schedule | Versioned releases that callers pin | A caller changes only when it bumps its pinned version |
The deciding question for shared modules is who controls when a caller changes. A release that a caller pins changes only when that caller edits its version; any reference that tracks a moving target delivers every change to every caller at the same moment. Note also that a module removes duplicated code; how state is split between root configurations is a separate decision that modules do not change.
When not to write a module
HashiCorp's module development guidance warns against thin wrappers around a single resource type. A module that contains one aws_security_group and passes every argument through (or merely renames the arguments) adds a second interface without adding an abstraction. The guidance offers a test: if you struggle to name the module anything other than the resource type inside it, use the resource directly.
How do you decide the scope of a module?
Scope a module so that each one has a single owner and a single rate of change. HashiCorp's module creation pattern names three tests:
| Test | Question to ask | Example |
|---|---|---|
| Encapsulation | Is this infrastructure always deployed together? | A cache cluster, its subnet group and its parameter group that are always created together belong in one module. |
| Privileges | Does it cross a team's privilege boundary? | IAM permission boundaries owned by the platform team stay apart from code that product teams deploy. |
| Volatility | Is it long-lived or short-lived? | A shared message bus that is rarely touched is kept apart from consumers that are redeployed daily. |
The tests can pull in different directions, and you apply each where it fits: merge pieces that are always deployed together (encapsulation) while still separating long-lived from fast-changing parts (volatility). Lifecycle guards protect a long-lived resource but leave it coupled to whatever it is deployed with, so they do not replace a separate module. A dependency between two pieces does not force them into one module either; the dependency is expressed by passing an ID or ARN as an input. The opposite extreme — one module per resource type — is also wrong, because it brings back thin wrappers.
Small, composable modules
The module composition guidance recommends small modules that root modules compose, a flat module tree (avoid deep nesting) and letting the caller decide what exists. A large shared module that grows dozens of inputs and create_* booleans, so that each team can switch on its subset, mixes owners and change rates: a change for one team breaks another team's plan. The cure is structural: splitting it into small modules that each root composes, passing dependencies between them as inputs, is the recommended refactor.
What are dependency inversion and data-only modules?
Dependency inversion means a module receives the objects it depends on as input variables instead of creating or looking them up itself. A database module takes vpc_id and subnet_ids; the root module decides whether those come from a network module in the same configuration or from a network that already exists elsewhere. This lets one module serve both callers without conditional create_vpc flags or count switches.
module "network" {
source = "./modules/network"
}
module "db" {
source = "./modules/db"
vpc_id = module.network.vpc_id
subnet_ids = module.network.private_subnet_ids
}
Inputs also fix a subtle ordering problem. A data source whose arguments depend only on a string variable (for example a security group found by its name) is read during planning. If the same root creates that security group in the same run, the lookup runs before the object exists and fails, and a name-based lookup also assumes every caller follows the same naming convention. Passing aws_security_group.app.id as an input both supplies the value and gives Terraform the dependency.
A data-only module contains only data sources and outputs: it retrieves shared values (subnet IDs, DNS zones) and returns them. It raises the abstraction around how the values are found — terraform_remote_state, tag lookups, a parameter store — so the method can change in one place. Combined with dependency inversion (consumers take subnet_ids as plain inputs), switching the publishing method needs only a new version of the data-only module and a version bump in each root.
How do you move existing resources into a module without recreating them?
Use moved blocks. When a resource's address changes, Terraform otherwise sees an old address removed and a new one added and plans destroy and create. Terraform never guesses a move by itself. A moved block records that the object at the old address is now at the new one; Terraform shows the move in the plan, so it goes through normal review, and records the new address in state when that plan is applied.
# root module
module "audit_trail" {
source = "./modules/audit-trail" # contains aws_cloudwatch_log_group.this
}
moved {
from = aws_cloudwatch_log_group.audit
to = module.audit_trail.aws_cloudwatch_log_group.this
}
Rules that decide where and how
- Write the block in the module that can see both addresses. Extracting from the root into a child: the root owns the old address and the module call, so the block goes in the root. A child module cannot refer to its caller's addresses.
- Direction matters:
fromis the old address,tothe new. Reversing them plans the opposite move. - Renaming a module call moves every resource inside it; one block in the caller,
from = module.api/to = module.backend, covers them all. - Instance keys move with
for_each. Ifaws_dynamodb_table.tablesusedfor_eachand the module call now does, the key sits on the module call:module.table["users"].aws_dynamodb_table.this. Write one block per key, e.g.from = aws_dynamodb_table.tables["users"]. The key always goes on whichever block carriesfor_each, in both the old and the new address. - Refactoring inside a shared module: when a new release of a module reorganises its resources into nested modules, put the
movedblocks in the module itself (from = aws_ecs_cluster.this,to = module.cluster.aws_ecs_cluster.this). The original module acts as a shim, and every caller receives the moves simply by upgrading.
| Tool | Effect when refactoring |
|---|---|
moved block | Move shown in the plan and recorded in state on apply; travels with module releases |
terraform state mv | Same rename, but run by hand per working directory and outside the reviewed plan |
import block | Binds an existing object to a new address, but the old address still plans a destroy |
What is the contract of a Terraform module?
A module's contract is everything its callers can depend on: the input variables they set, the outputs they reference, the provider requirements it declares and — in practice — the resource addresses recorded in each caller's state. Everything else is private: locals, internal resource expressions, how resources are wired together.
- Callers cannot reach inside a module.
module.network.aws_vpc.this.idis not a valid reference; the author must add an output and the caller usesmodule.network.vpc_id. Outputs are the only channel from a child module to its caller. - Renaming a local is invisible to callers. Renaming a variable or output breaks callers' configurations. Renaming a resource without a
movedblock changes addresses in callers' state and plans a destroy. - Keep outputs narrow. An output whose value is a whole resource (
value = aws_db_instance.this) makes every attribute of that resource type — and the fact that it is that resource type — part of the contract. Outputs such asendpointandportexpose only what callers need, so the module can later restructure its internals and rewire the outputs without breaking anyone.
How should a module's inputs be designed?
Inputs are the half of the contract callers write against, so make them few, typed, described and honest about what is required.
- Required means no default. Only a variable with no
defaultis required: if it is not set, Terraform refuses to plan (or prompts for a value in an interactive terminal; with input disabled, as in CI, it errors). Any default at all makes the input optional, so a required value should never get one just to silence the error. - Describe every input and output. The standard module structure expects a
descriptionon each, and documentation tools generate the interface tables from them. - Prefer specific types over
any. Withtype = anya misspelled attribute is only discovered deep inside a resource expression. Withtype = object({ name = string, port = number, labels = map(string) })Terraform converts the caller's value on entry and a value missingportfails with an error that names the variable. An attribute declared optional without a default turns that same omission into a silentnull, so mark attributes optional only when a missing value is genuinely acceptable. - Expose only what callers legitimately vary. Fixed organisational standards belong inside the module, not in inputs — not even as
optional()defaults, because any caller can override a default.
optional() attributes and null
variable "backup" {
type = object({
vault = string
retention_count = optional(number, 7)
})
}
The default given in optional() is used both when the caller omits the attribute and when the caller sets it to null, so inside the module retention_count is 7 either way and is never null. That differs from a top-level variable, where an explicit null with nullable left at its default of true replaces the default with null.
How do outputs act as guarantees?
HashiCorp's composition guidance separates a module's assumptions (what it needs from inputs) from its guarantees (what consumers can rely on). Outputs carry the guarantees, and two output arguments enforce them.
| Mechanism | What it does | When to use it |
|---|---|---|
precondition in an output | If false, Terraform reports an error and stops before storing the output | The module promises something (e.g. an encrypted root volume) that inputs could break |
depends_on in an output | Makes the output — and everything that references it — wait for the listed objects | A hidden dependency the value's references do not express |
check block | Reports a warning; does not stop the run | Ongoing health checks, not a guarantee |
Example of a hidden dependency: an output key_arn = aws_kms_key.this.arn becomes known as soon as the key exists, before aws_kms_key_policy.this has granted the consuming service access, so a caller can start using a key it is not yet allowed to use. Adding the key policy to the output's depends_on list makes every reference to the output wait for it — fixed once in the module, for every caller, rather than in each consumer. HashiCorp describes output depends_on as rarely needed — use it only for dependencies like this one.
How should a module declare providers?
A reusable module declares the providers it needs and configures none of them. Provider configuration (Region, credentials, aliases) belongs to the root module, which either lets the child inherit its default configuration or passes configurations with the providers argument on the module block.
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.70.0"
}
cloudflare = {
source = "cloudflare/cloudflare"
}
}
}
- Every module declares its own
required_providers. Requirements are not inherited from the caller. If a module uses a local name it has not declared, Terraform assumeshashicorp/<name>, so an undeclaredcloudflaremakesterraform initlook forhashicorp/cloudflare. Declaring the correct source address in the module is the only fix. - Minimum versions with
>=. Declare the lowest provider version the module is known to work with. Avoid maximum constraints such as~>in shared modules: they force callers to upgrade many modules in lock-step. Maximums belong in root modules. - The lock file belongs to the root.
.terraform.lock.hclrecords selections for a root working directory; a module's committed lock file does not constrain its callers.
Why legacy provider blocks in modules cause trouble
A module that contains its own provider block (an old pattern) is incompatible with for_each, count and depends_on on its module block. The modern pattern — required_providers in the module, configurations passed from the root — removes the restriction. It also makes removal awkward: each resource in state records the provider configuration that manages it. Delete the whole module block and that configuration disappears with it, so the plan fails because the provider configuration is no longer present — Terraform does not fall back to a similar configuration in the root. Destroy the module's resources first, then remove the block.
Which module changes break callers, and how do you deprecate safely?
A change is breaking if an existing caller that only bumps the version would fail to plan or would see a planned destroy. Under semantic versioning, breaking changes need a new major version; compatible additions are a minor version; fixes are a patch.
| Breaking (major) | Compatible (minor or patch) |
|---|---|
| Adding a variable with no default (now required) | Adding an optional variable whose default preserves behaviour |
| Changing an input's type so callers' values no longer convert | Adding an optional attribute with a default to an object input |
| Adding validation that rejects values callers could pass before | Adding a new output |
| Renaming or removing a variable or an output callers use | Renaming a local value or editing descriptions |
Renaming a resource or changing for_each keys without moved blocks | Renaming a resource with a moved block |
Removing moved blocks callers may still need | Marking an input or output deprecated |
When can moved blocks be removed?
A moved block acts on each caller's state when that caller plans and applies. HashiCorp calls removing one a breaking change: a caller that skips the release containing it would see its old addresses as orphaned and plan destroy and recreate. Removal is reasonably safe only once every caller has applied a release that includes the blocks — something authors of a private module can confirm, and authors of a public one usually cannot, so they keep the blocks.
Deprecating inputs and outputs (Terraform 1.15)
From Terraform 1.15, variable blocks and the outputs of child modules accept deprecated = "<message>". Deprecation warns; it never fails a run, and it does not change how values are stored or passed. Root-module outputs cannot be marked deprecated; the argument is only for outputs that other modules consume.
- Deprecated variable: the message appears when the variable is set in a root module or when a caller passes a value for it. A caller that relies on the default sees nothing.
- Deprecated output: the warning appears to consumers that reference it from another module, not in the defining module's own runs.
ignore_nested_deprecations = trueon a module block hides deprecation warnings from that call and the modules nested under it, for callers that cannot act yet.
The compatible migration path: add the replacement (for example log_settings replacing log_bucket) alongside the old one, mark the old one deprecated in a minor release, and remove it in the next major release.
What is the standard module structure?
HashiCorp's standard module structure is the file and directory layout that tooling and registries expect. The minimum is main.tf (primary entry point), variables.tf and outputs.tf (the interface), and a README describing the module — even if some of these files start empty. A versions.tf holding required_providers is a common addition. Provider and backend configuration belong to root configurations, and variable values come from callers, so neither a backend block nor a terraform.tfvars file belongs in a reusable module.
terraform-aws-consul/
README.md
main.tf
variables.tf
outputs.tf
modules/
consul-cluster/
consul-security-group/
examples/
basic/
- Nested modules live under
modules/. Terraform does not enforce visibility, but a nested module with a README is treated as usable by external callers; one without is considered internal. Nested modules should not configure providers. - Calls from the root module to nested modules use relative paths, such as
source = "./modules/consul-cluster", so Terraform treats them as part of the same package instead of downloading another copy. - Examples live under
examples/and should call the module by the source address an external caller would use, because they are copied into other repositories. - Documentation of inputs and outputs is generated from their
descriptionarguments, so the README does not have to hand-list them.
Publishing a module to a registry and testing it with terraform test are covered in the companion lesson on the Terraform registry and module tests.
Tip. Expect production scenarios with a stated constraint — callers may only bump the version, no resource may be recreated, each team upgrades on its own schedule, the release must be minor — where every option is a real Terraform construct that fails only on that constraint. Refactoring items show for_each resources, module calls or nested-module splits and ask which moved blocks are needed and where they go. Contract items ask which change is safe without caller edits, what version number a release needs, how callers should receive a value, or what deprecation shows to which caller. Scoping items weigh encapsulation, privileges and volatility against each other, or compare thin wrappers, flag-driven catch-all modules and composed small modules. Provider items cover required_providers sources and minimum versions, legacy provider blocks and lock files. Multi-select items pair two facts, such as two breaking changes or two accurate statements about Terraform 1.15 deprecation.
- Write a module when resources are used together and repeated, or when standards must be enforced; avoid thin wrappers around one resource type.
- Reuse across repositories with independent upgrades needs versioned releases that callers pin, not a local path or a branch reference.
- Scope modules by encapsulation, privileges and volatility; prefer small composable modules over one module full of create_* flags.
- Pass dependencies in as inputs; use data-only modules to hide how shared values are retrieved.
- moved blocks go in the module that can see both addresses, carry instance keys where for_each lives, and let a shared module refactor itself for every caller.
- The contract is inputs, outputs, provider requirements and resource addresses; locals and internal wiring are private, and narrow outputs keep the contract small.
- Required inputs have no default; optional() defaults replace both omitted and null attributes; specific types catch caller mistakes on entry.
- Modules declare required_providers with a source and a >= minimum and contain no provider blocks.
- Breaking changes (new required inputs, type changes, removed outputs or moved blocks) need a major version; Terraform 1.15 deprecated warns without failing.
Frequently asked questions
When should I turn Terraform code into a module?
Turn code into a module when a group of resources is used together and repeated across configurations, when an organisational standard must be enforced the same way everywhere, or when the resources form a higher-level concept such as a web tier. Do not write a module that only wraps one resource type and passes its arguments through; use the resource directly.
What is a Terraform module contract?
A Terraform module contract is what callers depend on: the input variables they set, the outputs they reference, the providers the module declares, and the resource addresses stored in each caller's state. Locals and internal resource wiring are private, so they can change freely; changes to the contract can break callers.
Where do moved blocks go when I move a resource into a module?
Moved blocks go in the module that can see both the old and new addresses. When extracting resources from a root module into a child, that is the root module (for example from = aws_cloudwatch_log_group.audit, to = module.audit_trail.aws_cloudwatch_log_group.this). When a shared module restructures its own internals, the blocks go inside the module so every caller gets the move by upgrading.
Is removing a moved block a breaking change?
Yes. HashiCorp treats removing a moved block as a breaking change, because any caller that has not yet applied a release containing it would see the old addresses as orphaned and plan to destroy and recreate them. Remove moved blocks only when every caller is known to have applied a release that includes them.
Should a reusable Terraform module contain provider blocks?
No. A reusable module declares its providers in required_providers, with a source address and a minimum version using >=, and leaves provider configuration to the root module. A module with its own provider block cannot be used with for_each, count or depends_on, and deleting its module block leaves its resources without a provider configuration to destroy them.
How do I deprecate a module variable or output in Terraform 1.15?
In Terraform 1.15, add deprecated = "<message>" to the variable block or to an output of a child module (root-module outputs cannot be deprecated). Callers that set the variable, or consumers that reference the output, see the message as a warning; nothing fails. Add the replacement in the same minor release and remove the old one in the next major release. Callers can hide the warnings for a module call with ignore_nested_deprecations = true.
What happens if an object attribute with an optional() default is set to null?
The default declared in optional() is used both when the caller leaves the attribute out and when the caller sets it to null, so inside the module the attribute is never null. With optional(number, 7), a caller that omits the attribute and a caller that passes null both get 7.
Source
This lesson covers the "Advanced module usage and registry practices" domain of the official Terraform Pro 002 exam guide. Vendors revise their guides — check the source for the current version.
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.