Module Design Patterns
Creating a module is easy. Creating a good module is an architectural decision.
Once you know how inputs, outputs, sources, composition, and providers work, the harder question is no longer “How do I write a module?” It is “Where should the boundary be?”
A good module makes infrastructure easier to understand and reuse. A bad module merely moves complexity behind another directory.
What Belongs Inside A Module?
A module should normally contain resources and logic that belong to one coherent infrastructure responsibility.
For example:
Network Module
├── network
├── subnet
└── routingThese resources make sense together because they collectively implement networking.
Likewise:
Compute Module
├── instance
└── related compute resourcesThe exact boundary depends on the infrastructure, but the principle is consistent:
A module should have a reason to exist beyond the fact that Terraform allows directories.
Ask:
What does this component own?
What responsibility does it have?
What does a caller need to provide?
What should the caller receive?If you cannot answer those questions clearly, the boundary may not be ready.
What Should Stay Outside?
The root module is usually the place where application- or environment-level composition happens.
For example:
module "network" {
source = "./modules/network"
name = "production"
}
module "compute" {
source = "./modules/compute"
subnet_ids = module.network.subnet_ids
}The root module decides which components are combined.
A network module should not generally decide that a particular application must use a particular compute module.
That is a composition decision.
Think of it as:
Module:
"Here is the infrastructure component I own."
Root:
"Here is how these components fit together."This separation keeps modules reusable.
Reusable Modules And Application-Specific Modules
Not every module needs to be generic enough for every possible Terraform project.
There is a useful spectrum:
Highly Generic
│
│
▼
Reusable Infrastructure Component
│
│
▼
Application-Specific Component
│
│
▼
One-Off ImplementationA highly generic module may support many callers but can become difficult to understand.
An application-specific module may be easier to use because it makes stronger assumptions.
The right abstraction level depends on the problem.
For example, a company might have a reusable network module with a small interface. An application repository might then have a module specifically for that application’s infrastructure.
Both can be legitimate.
Generic Versus Opinionated
Consider two module designs.
A generic module might expose dozens of options:
subnet_count
subnet_cidr_1
subnet_cidr_2
routing_mode
nat_mode
dns_mode
enable_feature_a
enable_feature_b
...An opinionated module might expose:
environment
network_cidrand implement a known organizational pattern internally.
Neither is automatically better.
A generic module offers flexibility.
An opinionated module offers simplicity and consistency.
The important question is:
Which decisions should this module make, and which decisions genuinely belong to its callers?
If an organization always uses the same sensible network layout, forcing every caller to configure every detail may create unnecessary complexity.
Avoid Over-Abstraction
One of the most common module mistakes is assuming:
“Everything should be a module.”
It should not.
Suppose you have one resource:
resource "local_file" "example" {
filename = "example.txt"
content = "hello"
}Wrapping it immediately in:
modules/
└── example/
├── main.tf
├── variables.tf
└── outputs.tfdoes not necessarily improve anything.
Now the reader has to navigate through an additional abstraction to understand a trivial resource.
Abstraction has a cost.
Every module introduces:
- another directory
- another interface
- another place to look for implementation
- another layer of indirection
Use that cost when the boundary provides enough value to justify it.
Avoid Under-Abstraction
The opposite mistake is keeping everything in one enormous root module.
You might eventually end up with:
main.tf
├── network resources
├── compute resources
├── database resources
├── storage resources
├── monitoring resources
├── application resources
└── many environment-specific detailsEven if Terraform can manage it, the configuration can become difficult to understand and reuse.
At that point, meaningful boundaries can help:
Root
├── Network Module
├── Compute Module
├── Database Module
└── Storage ModuleThe goal is not “many modules.”
The goal is useful boundaries.
Stable Interfaces Matter
Once other configurations depend on a module, its inputs and outputs become a contract.
For example:
output "subnet_ids" {
value = aws_subnet.this[*].id
}A caller may depend on:
module.network.subnet_idsChanging or removing that output is therefore an interface change.
The same applies to variables.
If callers depend on:
variable "network_cidr" {
type = string
}then changing its meaning can break consumers even if Terraform itself still accepts the configuration.
Think of a module interface like an API.
Inputs ──► Module ──► Outputs
▲ │
└────── contract ─────┘The implementation can evolve, but the contract should change only deliberately informing changes to stakeholders (teammates or customers).
Breaking Changes
A breaking module change might include:
- removing an input
- renaming an input
- changing the meaning of an input
- removing an output
- changing an output’s structure
- changing defaults in a way that alters expected behavior
When such changes are necessary, version the module appropriately and mention changelogs so callers can deliberately adopt the new interface.
This is one reason module versioning matters even if the module itself is not a large software project.
Naming Matters
Names should communicate the infrastructure concept.
Prefer:
network
compute
storage
databaseover names that describe implementation trivia.
Inside modules, consistent names help too:
resource "aws_vpc" "this" {
...
}Using this is a common convention for the primary resource when the module itself gives the resource its context.
There is no requirement to use this convention. The important thing is consistency and clarity.
Resource Naming And Module Naming Are Different
A module might be called:
module "network" {
source = "./modules/network"
}while internally it contains:
resource "aws_vpc" "this" {
...
}The caller cares about the module name and its outputs.
The module implementation cares about its internal resource names.
Do not force callers to understand internal names just to use the module.
Document The Interface
A reusable module should make its interface understandable without requiring someone to read every resource first.
Variable descriptions help:
variable "network_cidr" {
description = "CIDR block assigned to the network."
type = string
}Output descriptions help too:
output "network_id" {
description = "ID of the created network."
value = aws_vpc.this.id
}Good names, types, descriptions, and sensible structure are part of module usability.
Documentation is not merely an extra file. The interface itself should communicate the module’s purpose.
Keep Inputs Meaningful
Compare these two approaches.
The first exposes implementation details:
vpc_enable_dns_support
vpc_enable_dns_hostnames
subnet_map_public_ip_on_launch
subnet_assign_ipv6_address
...The second exposes a higher-level decision:
network_cidr
subnet_cidrsThe first may offer more control.
The second may provide a cleaner abstraction.
Neither is universally correct, but if every underlying provider argument becomes a module variable, the module is often just a thin wrapper around the provider resource.
A module should add enough structure or ownership to justify its boundary.
Module Composition Is Often Better Than Giant Modules
Suppose you need:
Network
Compute
StorageYou could create one enormous module:
application-infrastructure/
├── network
├── compute
└── storagewith dozens of inputs controlling every part.
Or you could compose smaller meaningful modules:
Root
├── Network Module
├── Compute Module
└── Storage ModuleThe second design gives each component a clearer responsibility.
The root module then connects them:
module "compute" {
source = "./modules/compute"
subnet_ids = module.network.subnet_ids
}This makes the architecture visible in the configuration.
A Useful Abstraction Test
Before creating a module, ask:
Does It Have A Clear Responsibility?
If you cannot describe the module in one or two sentences, it may be too broad.
Does It Have A Clear Interface?
Can you explain its inputs and outputs without explaining every internal resource?
Is The Boundary Useful?
Will the module reduce duplication, isolate implementation, or represent a meaningful ownership boundary?
Will Reuse Actually Happen?
You do not need to predict the future perfectly, but creating an abstraction solely because reuse might someday happen can be premature.
Does The Module Make The Caller Simpler?
This is perhaps the best test.
If the caller becomes harder to understand after introducing the module, ask whether the abstraction is worth it.
Design Exercise: A Growing Configuration
Imagine that a Terraform project has grown to contain:
network resources
application servers
object storage
database
monitoring resources
application-specific configurationThe first instinct might be:
“Put every group into a module.”
Instead, examine responsibility.
A reasonable architecture might become:
Root Module
│
├── Network Module
│
├── Compute Module
│
├── Storage Module
│
└── Database ModuleThe monitoring resources might or might not deserve their own module.
If monitoring is a reusable organizational component, a module may make sense.
If the monitoring configuration is tiny and completely specific to this application, keeping it in the root module may be clearer.
Application-specific configuration might also remain outside reusable infrastructure modules if it is primarily a composition concern.
The point is not to arrive at one universally correct answer.
The point is to explain the boundary.
Design Exercise: Where Should The Boundary Be?
Suppose you have these resources:
VPC
Subnet A
Subnet B
Route table
EC2 instance A
EC2 instance B
S3 bucketA possible design is:
Network Module
├── VPC
├── Subnet A
├── Subnet B
└── Route table
Compute Module
├── EC2 instance A
└── EC2 instance B
Storage Module
└── S3 bucketThe root module connects them.
Why?
Because networking, compute, and storage represent different responsibilities.
Now imagine that there is only one EC2 instance and no expectation that the compute implementation will be reused or independently maintained.
It might be perfectly reasonable to keep that resource in the root module instead of introducing a compute module immediately.
That is the distinction between modular architecture and module for everything.
Public And Private Reuse
A module can be useful without ever becoming public.
You might have:
Organization
├── network module
├── compute module
└── storage moduleused across internal configurations.
Alternatively, a broadly useful module might be published for wider consumption.
The same design principles apply in both cases:
- stable interface
- meaningful inputs
- useful outputs
- predictable behavior
- deliberate versioning
- clear ownership
Public visibility does not determine whether something is well designed.
A Practical Rule For Module Boundaries
A good rule of thumb is:
Create a module around a meaningful infrastructure responsibility, not around an arbitrary number of resources.
A module might contain one resource if that resource represents a useful reusable component.
A module might contain twenty resources if those resources collectively implement one coherent responsibility.
Resource count is not the deciding factor.
The boundary is.
The Final Mental Model
Throughout this section, keep returning to this picture:
Module Interface
┌─────────────────────┐
Inputs ──►│ │──► Outputs
│ Module │
│ │
│ implementation │
│ inside │
└─────────────────────┘The caller should primarily care about:
What does this module need?
What does this module provide?The module should own:
How is that infrastructure implemented?And the root module should generally own:
How do these infrastructure components fit together?That gives you three useful layers:
Root Module
│
│ composition
▼
Child Modules
│
│ implementation
▼
InfrastructureThe goal of modular Terraform is not to hide everything.
It is to put the right decisions in the right place.
When a growing configuration makes you think, “This should become a module,” stop for a moment and ask one more question:
What meaningful boundary would this module create?
If you can answer that clearly, you are probably moving toward a better Terraform architecture.