Terraform Modules: Composition Over Abstraction
Terraform makes it easy to build large, highly abstracted modules that try to solve everything in one place. At first glance, this feels efficient: fewer modules, fewer calls, less wiring.
In practice, that approach often creates more problems than it solves.
A better pattern-especially as infrastructure grows-is to design small, focused, “atomic” modules and combine them using composition.
What We Mean by Composition
In this context, composition means:
Building infrastructure by combining smaller, independent modules instead of hiding everything behind a single, all-in-one module.
Instead of:
- one module doing everything internally
you have:
- multiple modules wired together explicitly
module "role" { ... }
module "policy" { ... }
module "attachment" {
role = module.role.name
policy = module.policy.arn
}
This is not just a stylistic choice-it directly impacts maintainability, safety, and clarity.
The Problem with “Do-It-All” Modules
A common anti-pattern is a module that:
- creates multiple IAM roles
- generates multiple policies
- attaches them
- conditionally enables features via flags
- supports multiple unrelated use cases
Example:
module "irsa" {
source = "./modules/irsa"
roles = {
service_a = {
policies = ["s3", "dynamodb"]
}
service_b = {
policies = ["sqs"]
}
}
}
This looks convenient, but introduces several issues.
Hidden Coupling
All resources share one lifecycle.
A small change:
- can affect unrelated roles
- may trigger unnecessary diffs
- increases risk during apply
Poor Reusability
“Generic” modules often become:
- too opinionated for some use cases
- too complex for others
Consumers either:
- fight the interface
- or reimplement logic elsewhere
Hard-to-Review Changes
A simple modification may:
- touch multiple resources
- impact different logical paths
This makes it difficult to answer:
What will this change actually do?
Large Blast Radius
Terraform operates at the module/state level.
Large modules lead to:
- bigger plans
- slower applies
- harder rollbacks
- increased risk
Atomic Modules: A Better Approach
“Atomic” does not mean artificially small.
It means:
A module should represent a single logical responsibility.
Examples:
iam-roleiam-policyiam-role-policy-attachment
Each module:
- has a clear purpose
- exposes a minimal interface
- can be reused independently
Example: IRSA - Monolith vs Composition
Monolithic Approach
module "irsa" {
source = "./modules/irsa"
roles = {
service_a = {
policies = ["s3", "dynamodb"]
}
}
}
Problems:
- tightly coupled lifecycle
- unclear ownership
- difficult to modify safely
Composed Approach
Create role
module "service_a_role" {
source = "./modules/iam-role"
name = "service-a"
assume_role_policy = data.aws_iam_policy_document.irsa.json
}
Create policy
module "service_a_s3_policy" {
source = "./modules/iam-policy"
name = "service-a-s3"
policy = data.aws_iam_policy_document.s3.json
}
Attach policy
module "service_a_attach_s3" {
source = "./modules/iam-role-policy-attachment"
role_name = module.service_a_role.name
policy_arn = module.service_a_s3_policy.arn
}
Why Composition Works Better
Clear Ownership
Each resource:
- is defined explicitly
- belongs to a specific consumer
Safer Changes
Updating a policy:
- affects only that policy
- does not impact unrelated roles
Better Reusability
You can:
- reuse policies across roles
- attach policies flexibly
- compose behavior without modifying modules
Easier Debugging
Failures are easier to trace because:
- modules are small
- responsibilities are clear
Composition vs Abstraction
These are often confused.
| Approach | Focus | Trade-off |
|---|---|---|
| Abstraction | Simplicity of usage | Hidden complexity, rigidity |
| Composition | Flexibility, clarity | More explicit wiring |
Monolithic modules favor abstraction.
Atomic modules favor composition.
Trade-offs of Composition
This approach is not free.
More Module Calls
You will write more blocks.
This increases verbosity, but also improves clarity.
More Explicit Wiring
You pass outputs between modules.
This is intentional:
- dependencies are visible
- behavior is predictable
Risk of Over-Fragmentation
Splitting everything blindly leads to:
- unnecessary complexity
- modules that are never used independently
Example of going too far:
- separating tightly coupled resources that must always exist together
Practical Rule of Thumb
If a resource can be safely changed, reused, or destroyed independently, it should likely be its own module.
If not, keep it together.
When Larger Modules Still Make Sense
There are valid cases for bigger modules:
- tightly coupled infrastructure (e.g., VPC with subnets and routing)
- opinionated platform layers
- internal “productized” infrastructure
Even then:
- avoid “god modules”
- keep boundaries clear
- prefer internal composition
Key Insight
The real shift is this:
Move complexity from inside modules to between modules.
- Monolith → implicit complexity
- Composition → explicit complexity
In infrastructure systems, explicit complexity is easier to manage over time.
Summary
- Prefer composition over monolithic abstraction
- Design modules with single responsibilities
- Keep dependencies explicit and visible
- Accept some verbosity in exchange for safety and clarity
The goal is not smaller modules.
The goal is predictable, composable, and low-risk infrastructure.
Originally published at https://bard.sh/posts/terraform-modules-composition-over-abstraction/
Top comments (0)