Terraform / Modules and state-aware lifecycle / Lesson 8
Module contracts and provider scope
Build small reusable interfaces and distinguish a child module boundary from a separate state or authorization boundary.
On this page
Lesson goals
- Explain local module sources, input/output contracts, and root ownership.
- Separate provider requirements from provider configurations passed by callers.
A module groups configuration behind an interface
A module is a directory of Terraform configuration files evaluated as a unit. A root module calls child modules through source addresses and arguments, and consumes their declared outputs. Files within one module are not separate execution scripts. A module boundary can improve reuse and review, but does not automatically create a separate state, backend lock, credential set, or approval process. Child resources normally remain within the calling root’s state. Choose operational boundaries explicitly instead of assuming directory structure provides isolation.
The root states what the consumer needs
The root example below calls a local module at ./modules/catalog. Its input is a meaningful service name and its output is a narrow name contract. The local path requires the child files shown next; it is not a placeholder for a registry download. A version argument applies to supported registry module sources, not this local directory form. Keep source selection and module-version policy separate from provider locking, which does not pin remote module releases. No module is initialized or executed here.
terraform {
required_version = ">= 1.16.0, < 1.17.0"
}
module "catalog" {
source = "./modules/catalog"
service_name = "atlas-api"
}
output "catalog_name" {
value = module.catalog.name
}
The caller publishes only the child’s name output rather than every internal resource attribute. The source directory and child interface are explicit. This is one root module with a child, not two independently approved states or separate cloud accounts.
Keep the child interface smaller than its implementation
The child declares its input type and domain rule, stores synthetic metadata in a built-in resource, and returns one value. Internal resource names are implementation details, but their state addresses still matter during refactoring. Changing an output type or validation rule can break callers even if the internal resource count does not change. Review compatibility from the caller’s perspective and document unsupported combinations. A reusable module should encode intentional decisions rather than merely wrap every provider argument with an identical variable.
variable "service_name" {
type = string
nullable = false
validation {
condition = startswith(var.service_name, "atlas-")
error_message = "Service names must start with atlas-."
}
}
resource "terraform_data" "this" {
input = { name = var.service_name }
}
output "name" {
value = terraform_data.this.input.name
}
Place this file under modules/catalog for the preceding caller. atlas-api satisfies the declared prefix rule and is the logical name returned through the interface. No resource application or actual output display is asserted. A real consumer needs to review naming uniqueness and provider-specific constraints separately.
Requirements and configurations cross module boundaries differently
Each module declares the provider sources and compatibility requirements it uses. Provider configurations carry settings such as target account, region, or endpoint and are normally owned by the root and supplied to children through supported inheritance or explicit mappings. Aliased configurations require deliberate wiring and appropriate child declarations. An alias is not a second provider binary version. Review which configuration a child actually receives before claiming it manages the intended environment. A correct module source can still be paired with the wrong execution identity or target.
Reuse does not remove lifecycle responsibility
A module update can alter defaults, resource identities, provider requirements, output contracts, or lifecycle behavior. Review the resulting complete plan under the root’s state and execution context rather than approving a source-version bump alone. Keep modules cohesive enough that their ownership, failure modes, and tests are understandable. When different teams need independent state, locks, credentials, or release timing, consider separate root configurations and explicit data publication. Splitting files into more child modules does not by itself deliver those operational guarantees.
Common errors and fixes
Unsupported argument at a module call.
- Why it happens
- The caller supplies a name not declared in the child input interface.
- How to fix it
- Align the call with the selected module’s documented input contract.
- Prevent it next time
- Review module interfaces and versions together.
A child uses an unintended provider configuration.
- Why it happens
- Inheritance or alias mapping was assumed rather than reviewed.
- How to fix it
- Inspect the root-to-child provider mapping and intended execution target.
- Prevent it next time
- Document provider ownership separately from module source ownership.
A module folder is treated as a separate state boundary.
- Why it happens
- Code organization was confused with root-level backend and lifecycle ownership.
- How to fix it
- Choose explicit root configurations when independent operational boundaries are required.
- Prevent it next time
- Map state, lock, credentials, and release scope before decomposing modules.
Check your understanding
1. Where do the child’s terraform_data objects belong in the example?
Show worked solution
They belong to the calling root configuration’s managed state, with a module path in their addresses. The local source directory does not create an independent backend or approval boundary. Understand that shared lifecycle before reusing the child in a larger root.
2. Why return name rather than the entire terraform_data.this object?
Show worked solution
The narrow output defines exactly what callers may depend on and leaves unrelated internal attributes free to evolve. Broad object outputs can couple callers to implementation details and provider schema changes. Choose a stable typed contract and version any breaking interface changes deliberately.
Key takeaways
- A child-module boundary is an interface boundary, not automatic operational isolation.
- Version inputs, outputs, sources, and provider wiring as explicit contracts.