← All articles
Published Dec 13, 20258 min read

Terraform: Unlock Flexibility with the Open-Closed Principle

Discover how to build modules that grow with your team. Learn to design for extension and create a 'batteries-included' experience without the bloat.

Gabriel Levasseur

Gabriel Levasseur

Founder

The Flag Explosion

Have you ever spent more time scrolling the variables for a module than it would've taken you to write the module on your own?

You know the one. You just want a simple S3 bucket, but you have to wade through a swamp of configuration first. You dodge flags for versioning, skip over lifecycle rules, and ignore specific CORS policies just to find the one input that actually matters. You drown in enable_this, with_that, and custom_config_for_steve.

Or perhaps you've found a module that almost does what you want, but it makes one fatal assumption about your networking setup that renders it useless. You end up fighting against the abstraction rather than benefiting from it.

This is often caused by violating the Open-Closed Principle, and it’s making your modules harder to use and understand.

What is the Open-Closed Principle?

Before we talk Terraform, let's talk software design. The Open-Closed Principle (OCP) states that software entities should be open for extension, but closed for modification.

In traditional programming, this prevents you from having to rewrite core logic every time you add a new feature. While Terraform isn't object-oriented, the Open-Closed Principle originated in software engineering. Let's look at a classic Python example to understand the mechanics before applying it to HCL.

The Trap: Modifying Core Logic Take a look at this code. Can you spot the trap?

# area_calculator.py
class AreaCalculator:
    def calculate(self, shapes):
        area = 0
        for shape in shapes:
            if isinstance(shape, Rectangle):
                area += shape.width * shape.height
            elif isinstance(shape, Circle):
                area += shape.radius * shape.radius * 3.14
        return area

The problem is that every time we want to support a new shape, we have to modify the AreaCalculator class. This introduces regression risk. You aren't just adding a feature; you are modifying logic that is already working in production. A mistake here could break Rectangle calculations for existing users. Furthermore, every modification requires you to update and re-run the test suite for the entire calculator, rather than just testing the new Circle logic in isolation.

The Solution: Extending via Contracts We define a contract (an abstract method) that allows the AreaCalculator to treat every shape exactly the same. This closes the calculator to modification because it never needs to know about new shapes. But it opens the system to extension because you can create a Triangle class tomorrow, and the calculator will support it automatically.

# shapes.py
class Shape(ABC):
    @abstractmethod
    def area(self): pass

class Rectangle(Shape):
    def area(self): return self.width * self.height

class Circle(Shape):
    def area(self): return self.radius * self.radius * 3.14

class AreaCalculator:
    def calculate(self, shapes):
        return sum(shape.area() for shape in shapes) # <--- Closed for modification!

How OCP Applies to Terraform

In Terraform, we don't have classes or inheritance, but we have Modules.

We don't have abstract interfaces or inheritance to enforce contracts. So how do we open a module for extension while closing it for modification?

We use outputs strategically.

By outputting the "Identity" of your resources (like an IAM Role name or a Security Group ID), you give consumers the handle they need to attach their own logic from the outside. You are effectively saying, "Here is the resource I created; feel free to attach whatever policies or rules you need to it."

The Key to OCP in Terraform

Write your module once, lock it down, but give people the keys to build ON TOP of it.

Designing for Extension

To apply this to Terraform, we need to shift our mindset. Instead of asking, "What features should I put inside this module?", ask "What outputs do I need so people can build outside of it?"

Let's look at a classic offender: The IAM Role.

The "Kitchen Sink" Approach (Anti-Pattern)

In this common pattern, you try to predict every permission anyone will ever need. It’s a losing game.

# modules/service/variables.tf
variable "enable_s3_access" {
  type    = bool
  default = false
}

variable "extra_policy_arns" {
  type    = list(string)
  default = []
}

# modules/service/main.tf
resource "aws_iam_role" "this" { ... }

resource "aws_iam_role_policy_attachment" "s3" {
  count      = var.enable_s3_access ? 1 : 0
  role       = aws_iam_role.this.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonS3ReadOnlyAccess"
}

resource "aws_iam_role_policy_attachment" "extra" {
  count      = length(var.extra_policy_arns)
  role       = aws_iam_role.this.name
  policy_arn = var.extra_policy_arns[count.index]
}

The OCP Approach

Your module does its core job: it creates the role. That’s it. It outputs the name, and drops the mic.

# modules/service/main.tf
resource "aws_iam_role" "this" { ... }

# modules/service/outputs.tf
output "iam_role_name" {
  value = aws_iam_role.this.name
}

Now, the consumer gets to be the hero of their own story:

# main.tf
module "app" {
  source = "./modules/service"
  # ... just the basics!
}

# BOOM! Custom extension without touching the module
resource "aws_iam_role_policy" "s3_access" {
  name = "s3-access"
  role = module.app.iam_role_name # <--- The magic key

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = ["s3:GetObject"]
        Effect = "Allow"
        Resource = "arn:aws:s3:::my-bucket/*"
      }
    ]
  })
}

See that? The module didn't change. The consumer got exactly what they needed. Everyone is happy.

Security Groups: Avoid Hardcoded Assumptions

Security Groups often suffer from rigid inputs. If your module accepts a list of ingress_cidrs, you are assuming the user only wants to allow traffic from IP ranges.

But what if they need to allow traffic from another Security Group? Or a Prefix List? Or add a description to the rule?

If you hardcode the rule creation inside the module, you block these valid use cases. By outputting the Security Group ID, you allow the consumer to define the exact rules they need using aws_security_group_rule resources, without needing to submit a PR to your module to add variable "ingress_source_security_group_id".

Don't do this:

# main.tf
module "db" {
  source = "./modules/rds"
  ingress_cidrs = ["10.0.0.0/8"] # Blocks SG-to-SG rules!
}

Do this instead:

# main.tf
module "db" {
  source = "./modules/rds"
}

# Attach the rule from the outside!
resource "aws_security_group_rule" "allow_vpn" {
  type              = "ingress"
  from_port         = 5432
  to_port           = 5432
  protocol          = "tcp"
  cidr_blocks       = ["10.0.0.0/8"]
  security_group_id = module.db.security_group_id # <--- Extension point!
}

When to Close the Door (and When to Wrap It)

"But wait," you say, "I want my module to be flexible! I want to handle every use case my team throws at me."

That is the goal, but adding more flags doesn't create flexibility, it creates complexity. Real flexibility comes from the Open-Closed Principle: building a solid core that can be extended without modification. To get there, we need to distinguish between Identity and Capability.

The Litmus Test: Identity vs. Capability
  • Identity (Intrinsic): Properties that define what the resource is. An RDS instance must have an engine version and an instance class. A VPC must have a CIDR block. These belong inside the module because the resource cannot exist without them.
  • Capability (Extrinsic): Properties that define what the resource can do or who can talk to it. IAM policies, Security Group rules, and Lifecycle policies are often capabilities. These are perfect candidates for extension.

If a change requires you to add a count or a boolean flag, it's a strong signal that you are crossing the line from Identity to Capability. Stop, output the ID, and let the consumer handle it.

But what about the "Golden Path"?

You might argue, "But 80% of my users need this specific lifecycle policy! I don't want them to copy-paste it every time."

This is a valid requirement, but it shouldn't burden the users who don't need it. Instead of bloating the core module with optional logic, we can achieve this through Composition.

  1. Core Module: The clean, OCP-compliant resource (e.g., modules/s3-core).
  2. Pattern Module: A wrapper that calls the Core Module and adds the standard policies, alarms, and rules that most teams need (e.g., modules/s3-standard-app).

The 80% get the "batteries-included" experience by calling the Pattern Module, which extends the Core Module for them:

# modules/s3-standard-app/main.tf
# This is a NEW module that users call. It wraps the core module.

module "bucket" {
  source = "../s3-core" # The clean, simple Core Module
  name   = var.name
}

# The Pattern Module adds the "standard" opinionated extras automatically
resource "aws_s3_bucket_lifecycle_configuration" "standard" {
  bucket = module.bucket.id
  rule {
    id     = "expire-old-versions"
    status = "Enabled"
    expiration {
      days = 90
    }
  }
}

# modules/s3-standard-app/outputs.tf
output "bucket_id" {
  value = module.bucket.id
}

# main.tf (User's code)
module "my_app_bucket" {
  source = "./modules/s3-standard-app" # Using the batteries-included version
  name   = "my-app-logs"
}

The 20% with weird requirements use the s3-core module directly and extend it themselves. Everyone wins, and no one is fighting with boolean flags.

The Payoff

Adopting the Open-Closed Principle transforms how your team interacts with infrastructure:

  1. Simplicity: Your modules become small, focused, and easy to understand. Documentation fits on a single screen.
  2. True Flexibility: By using composition, you can handle infinite edge cases without modifying the core module.
  3. Velocity: Developers stop waiting for module maintainers to merge PRs for new flags. They simply import the module, attach what they need, and ship it.

So next time you find yourself adding yet another boolean flag to handle a specific edge case, consider the alternative. Expose the resource ID, and empower your users to define their own configuration.

Try this today: Open your largest, most painful module. Find one boolean flag that toggles a resource, and try refactoring it out using this pattern. Your future self will high-five you for it.

Visualize your entire AWS infrastructure with Cora

See your infrastructure as an interactive diagram. Easy setup, no complexity.

Get Started Today!

Keep reading

View all