SaveMyCert
Log in
5 of 5 free questions left today·for 10 a day
Advanced module usage and registry practices

Terraform Professional (002): Refactoring into Modules and the Module Contract

15 min readTerraform Pro 002 · Advanced module usage and registry practicesUpdated

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.

What you’ll learn
  • 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?

NeedWhere the module livesWhy
Remove repetition inside one repositoryLocal path, e.g. ./modules/queue-setSame repository, changes ship with the root that uses it
Reuse across several root configurations or teamsIts own repository with release tags, or a private registryCallers reference a release, not a copy
Each consumer upgrades on its own scheduleVersioned releases that callers pinA 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:

TestQuestion to askExample
EncapsulationIs this infrastructure always deployed together?A cache cluster, its subnet group and its parameter group that are always created together belong in one module.
PrivilegesDoes it cross a team's privilege boundary?IAM permission boundaries owned by the platform team stay apart from code that product teams deploy.
VolatilityIs 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: from is the old address, to the 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. If aws_dynamodb_table.tables used for_each and 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 carries for_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 moved blocks 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.
ToolEffect when refactoring
moved blockMove shown in the plan and recorded in state on apply; travels with module releases
terraform state mvSame rename, but run by hand per working directory and outside the reviewed plan
import blockBinds 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.id is not a valid reference; the author must add an output and the caller uses module.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 moved block 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 as endpoint and port expose 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 default is 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 description on each, and documentation tools generate the interface tables from them.
  • Prefer specific types over any. With type = any a misspelled attribute is only discovered deep inside a resource expression. With type = object({ name = string, port = number, labels = map(string) }) Terraform converts the caller's value on entry and a value missing port fails with an error that names the variable. An attribute declared optional without a default turns that same omission into a silent null, 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.

MechanismWhat it doesWhen to use it
precondition in an outputIf false, Terraform reports an error and stops before storing the outputThe module promises something (e.g. an encrypted root volume) that inputs could break
depends_on in an outputMakes the output — and everything that references it — wait for the listed objectsA hidden dependency the value's references do not express
check blockReports a warning; does not stop the runOngoing 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 assumes hashicorp/<name>, so an undeclared cloudflare makes terraform init look for hashicorp/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.hcl records 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 convertAdding an optional attribute with a default to an object input
Adding validation that rejects values callers could pass beforeAdding a new output
Renaming or removing a variable or an output callers useRenaming a local value or editing descriptions
Renaming a resource or changing for_each keys without moved blocksRenaming a resource with a moved block
Removing moved blocks callers may still needMarking 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 = true on 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 description arguments, 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.

Key takeaways
  • 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.

Spotted a mistake in this lesson?