IaCInfrastructure as Codeterraformterraform modules

Design Terraform Modules like a Software Engineer

Terraform Modules are more than reusable code. A well-designed module makes infrastructure easier to maintain, scale, and reuse across environments. This article explores module design from a software engineering perspective.

Jul 22, 20266 min readUpdated Jul 22, 2026
Design Terraform Modules like a Software Engineer

Design Terraform Modules like a Software Engineer

In the previous three articles, we explored the three most fundamental concepts of Terraform:

  • What Infrastructure as Code is.
  • How Terraform works internally.
  • Why Terraform State is the heart of the entire workflow.

At this point, you're probably able to write your first Terraform configurations.

But before long, you'll encounter a much more important question.

"How should I organize my Terraform code?"

This is where many engineers start taking the wrong path.

They see Terraform as nothing more than a tool for declaring cloud resources.

They put everything into a few .tf files, copy them into another project, make a few modifications, and repeat the process over and over again.

Everything seems fine in the beginning.

Until the infrastructure starts growing.

That's when you realize something important:

Infrastructure needs software engineering principles just as much as application code does.

Terraform Modules exist to solve exactly this problem.


We Don't Copy Terraform

Imagine your company has three different projects.

code
project-a/
    main.tf
 
project-b/
    main.tf
 
project-c/
    main.tf

Every project needs almost the same infrastructure:

  • VPC
  • Subnets
  • Security Groups
  • EC2 Instances
  • IAM Roles

The easiest solution?

Copy the entire Terraform directory.

Need another project?

Copy it.

Need another environment?

Copy it.

Need another customer?

Copy it again.

After a few months, you'll end up with dozens of almost identical directories.

Then one day your security team says:

"Every EC2 instance must use Instance Metadata Service Version 2."

It sounds like a small change.

But suddenly you need to:

  • Open every project.
  • Update every configuration.
  • Review every pull request.
  • Deploy every environment.

Miss just one project, and your infrastructure is no longer consistent.

This is exactly the kind of problem Software Engineering has been solving for decades.

Infrastructure is no different.


DRY Applies to Infrastructure Too

One of the most famous software engineering principles is:

DRY — Don't Repeat Yourself.

If the same logic appears in multiple places, you'll eventually pay the maintenance cost.

Terraform is no exception.

Consider the following configuration.

code
resource "aws_instance" "app1" {
  ...
}
 
resource "aws_instance" "app2" {
  ...
}
 
resource "aws_instance" "app3" {
  ...
}

There's nothing technically wrong with it.

Terraform will happily create all three instances.

However, if these resources are nearly identical, duplicating them everywhere makes your infrastructure increasingly difficult to maintain.

This is exactly why we create:

  • Functions in Java.
  • Components in React.
  • Services in Spring Boot.

Terraform Modules follow the same idea.

They allow us to reuse infrastructure without duplicating implementation.

But if you think Modules exist only to reduce duplicated code, you're still missing their true purpose.


What Is a Terraform Module?

Many Terraform beginners hear a definition like this:

A Terraform Module is simply a directory containing .tf files.

Technically, that's correct.

But it doesn't explain what a module really represents.

A much better definition is:

A Terraform Module is a reusable infrastructure component with a well-defined interface.

For example:

code
module "network"

Internally, this module may create:

  • VPC
  • Public Subnets
  • Private Subnets
  • Route Tables
  • Internet Gateway
  • NAT Gateway

The consumer doesn't need to know any of that.

They only need to understand:

  • What inputs the module requires.
  • What outputs it provides.

The internal implementation can evolve over time without affecting its users.

This is exactly the same concept as Encapsulation in Object-Oriented Programming.


A Terraform Module Is Closer to a Class than a Folder

This was the mindset that completely changed how I design infrastructure.

Let's compare.

A Java class.

code
public class DatabaseService {
 
}

It has:

  • Constructors
  • Public methods
  • Private methods
  • Internal state

Now look at a Terraform Module.

code
module "database" {
  source = "./modules/database"
 
  ...
}

A module also has:

  • Inputs (variables.tf)
  • Outputs (outputs.tf)
  • Internal resources (main.tf)
  • Internal logic (locals.tf)

Consumers only interact with the public interface.

They don't care how many resources exist inside the module.

Once you start thinking of Terraform Modules as classes instead of folders, the way you design infrastructure changes dramatically.


What Makes a Good Module?

Most Terraform Modules share a similar structure.

code
modules/
└── network
    ├── main.tf
    ├── variables.tf
    ├── outputs.tf
    ├── locals.tf
    ├── versions.tf
    ├── providers.tf
    └── README.md

The important part isn't the number of files.

It's that each file has a single, well-defined responsibility.

For example:

  • variables.tf defines inputs.
  • outputs.tf exposes outputs.
  • locals.tf contains intermediate values.
  • main.tf declares resources.
  • versions.tf defines Terraform and provider versions.

This is simply Separation of Concerns applied to Infrastructure as Code.


Your Interface Matters More Than Your Implementation

Many engineers spend most of their time optimizing the internals of a module.

Ironically, the most important part is usually the interface.

Imagine a module that requires more than thirty input variables.

code
module "ec2" {
  ...
}

Before anyone can use it, they have to read pages of documentation.

Now compare that with a simpler module.

code
module "web_server" {
  name          = "frontend"
  instance_type = "t3.micro"
  tags          = local.common_tags
}

The fewer required inputs a module has, the easier it becomes to understand and reuse.

A simple interface is almost always better than an extremely flexible but overly complicated one.


Don't Let a Module Know Too Much

One mistake I've seen repeatedly in real-world projects is the "mega module."

Imagine a single module responsible for:

code
Infrastructure Module
 
├── VPC
├── Subnets
├── EC2
├── IAM
├── ALB
├── Route53
├── CloudWatch
├── Auto Scaling
└── RDS

At first glance, this seems convenient.

One module creates an entire production environment.

In reality, it's an anti-pattern.

The module has accumulated far too many responsibilities.

A small change in one area can unexpectedly affect everything else.

If you've ever heard of the God Object anti-pattern in Object-Oriented Programming, this is its Infrastructure equivalent.


Compose Instead of Building Mega Modules

Rather than creating one enormous module, split your infrastructure into smaller building blocks.

For example:

code
network
 
database
 
compute
 
monitoring

Then compose them together.

code
production
 
├── network
├── database
├── compute
└── monitoring

This is called Composition.

Each module has a single responsibility.

A higher-level configuration assembles these independent pieces into a complete infrastructure.

Think of LEGO bricks.

You build large systems by combining many small pieces—not by pouring everything into one giant concrete block.


Don't Forget Versioning

Once multiple projects start consuming the same module, it effectively becomes a shared library.

At that point, versioning becomes essential.

For example:

code
module "network" {
  source = "git::https://github.com/company/iac-modules.git//network?ref=v1.2.0"
}

Instead of:

code
ref=main

Using version tags ensures every environment knows exactly which module version it is running.

It also makes upgrades, rollbacks, and testing much safer.


Common Mistakes I've Seen

After working on multiple Terraform projects, I've noticed the same mistakes appearing over and over again.

  • Designing modules that are too generic.
  • Exposing far too many input variables.
  • Creating deeply nested modules.
  • Copying modules instead of reusing them.
  • Hardcoding providers or regions inside modules.
  • Forgetting to expose useful outputs.
  • Shipping modules without proper documentation.

None of these issues prevent Terraform from working.

But all of them make your infrastructure harder to understand, evolve, and maintain.

Just like software, infrastructure isn't judged only by whether it works today.

It's judged by how easy it is to maintain tomorrow.


Conclusion

Terraform Modules are not about reducing the number of lines of code.

They exist to package infrastructure knowledge into reusable building blocks.

A good module isn't the one with the most features.

It's the one with:

  • A clear responsibility.
  • A simple interface.
  • Easy composition with other modules.
  • Long-term maintainability.

Once you start viewing Terraform Modules the same way software engineers design classes, you'll realize that Infrastructure as Code follows many of the same design principles as application development.

In the next article, we'll explore another fundamental concept that is often misunderstood:

How Terraform Providers really work—and why they're much more than simple Terraform plugins.

Comments

0/2000

Loading comments…