Skip to main content

Terraform Module Design Patterns — Composition Over Inheritance

· 6 min read
Goel Academy
DevOps & Cloud Learning Hub

A well-designed Terraform module is a force multiplier — one module can standardize infrastructure across 50 teams and prevent the same misconfiguration from happening twice. A poorly designed module is a different kind of multiplier: it spreads complexity, creates tight coupling, and makes every change a breaking change. The difference comes down to design patterns. Terraform does not have classes or inheritance, but it has something better: composition.

Module Composition Patterns

The fundamental question in module design is: do you build one large module or many small ones? The answer is almost always many small ones, composed together.

Atomic modules do one thing well:

# modules/vpc/main.tf — Creates a VPC and subnets
module "vpc" {
source = "./modules/vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
env_name = "production"
}

# modules/security-group/main.tf — Creates a security group
module "web_sg" {
source = "./modules/security-group"
vpc_id = module.vpc.vpc_id
name = "web-server"
ingress_rules = [
{ port = 443, cidr = "0.0.0.0/0" },
{ port = 80, cidr = "0.0.0.0/0" },
]
}

Composition modules wire atomic modules together for a specific use case:

# modules/web-service/main.tf — Composes VPC + SG + ALB + ASG
module "networking" {
source = "../vpc"
cidr = var.vpc_cidr
azs = var.availability_zones
env_name = var.environment
}

module "security" {
source = "../security-group"
vpc_id = module.networking.vpc_id
name = "${var.service_name}-sg"
ingress_rules = var.ingress_rules
}

module "load_balancer" {
source = "../alb"
vpc_id = module.networking.vpc_id
subnet_ids = module.networking.public_subnet_ids
security_groups = [module.security.security_group_id]
name = var.service_name
}

This layered approach keeps each module testable and reusable while providing higher-level abstractions for common patterns.

Opinionated vs Flexible Modules

Every module sits on a spectrum between opinionated (fewer inputs, more decisions baked in) and flexible (more inputs, user decides everything).

Opinionated module — enforces standards:

# modules/s3-bucket/main.tf
# This module ALWAYS enables encryption, versioning, and blocks public access.
# Teams cannot opt out of these security controls.

resource "aws_s3_bucket" "this" {
bucket = "${var.project}-${var.environment}-${var.name}"

tags = merge(var.tags, {
ManagedBy = "terraform"
Environment = var.environment
Project = var.project
})
}

resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = "Enabled"
}
}

resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "aws:kms"
}
}
}

resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}

Flexible module — user decides:

variable "enable_versioning" {
type = bool
default = true
}

variable "encryption_algorithm" {
type = string
default = "aws:kms"
}

variable "block_public_access" {
type = bool
default = true
}

Rule of thumb: be opinionated about security and compliance, be flexible about everything else. Teams should not be able to disable encryption, but they should be able to choose their bucket naming convention.

Module Interface Design

The interface of a module — its variables and outputs — is its contract with consumers. Design it carefully because changing it later is a breaking change.

Required vs optional variables:

# variables.tf

# Required — no default, must be provided
variable "vpc_id" {
type = string
description = "ID of the VPC where resources will be created"
}

variable "environment" {
type = string
description = "Environment name (e.g., production, staging)"

validation {
condition = contains(["production", "staging", "development"], var.environment)
error_message = "Environment must be production, staging, or development."
}
}

# Optional — sensible default, can be overridden
variable "instance_type" {
type = string
default = "t3.medium"
description = "EC2 instance type for the web servers"
}

variable "tags" {
type = map(string)
default = {}
description = "Additional tags to apply to all resources"
}

Output design — expose what consumers need:

# outputs.tf

# IDs for referencing in other modules
output "vpc_id" {
value = aws_vpc.main.id
description = "The ID of the VPC"
}

output "private_subnet_ids" {
value = aws_subnet.private[*].id
description = "List of private subnet IDs"
}

# ARNs for IAM policies
output "bucket_arn" {
value = aws_s3_bucket.this.arn
description = "ARN of the S3 bucket (for IAM policies)"
}

# Connection strings for applications
output "database_endpoint" {
value = aws_db_instance.main.endpoint
description = "Database connection endpoint (host:port)"
}

Do not expose internal implementation details. If your module creates a helper resource that consumers should never reference directly, do not create an output for it.

Module Versioning with Git Tags

Pin modules to specific versions using Git tags. This prevents upstream changes from breaking downstream consumers:

# Pin to a specific version
module "vpc" {
source = "git::https://github.com/company/terraform-modules.git//modules/vpc?ref=v2.3.1"
# ...
}

# Pin to a branch (less safe — branch contents can change)
module "vpc" {
source = "git::https://github.com/company/terraform-modules.git//modules/vpc?ref=main"
# ...
}

Use semantic versioning for module releases:

# Tag a new module version
git tag -a v2.4.0 -m "vpc: add support for IPv6 subnets"
git push origin v2.4.0
Version BumpWhenExample
Major (v3.0.0)Breaking changes to interfaceRemoving a variable, changing output type
Minor (v2.4.0)New features, backward compatibleAdding an optional variable
Patch (v2.3.1)Bug fixes, no interface changesFixing a tag propagation bug

Publishing to a Module Registry

Terraform Cloud and the public Terraform Registry provide versioned module hosting with documentation:

# Repository naming convention for public registry
# terraform-<PROVIDER>-<NAME>
# Example: terraform-aws-vpc

# Required structure
terraform-aws-vpc/
main.tf
variables.tf
outputs.tf
README.md
modules/
subnets/
main.tf
variables.tf
outputs.tf
examples/
simple/
main.tf
complete/
main.tf
# Consuming from the public registry
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.0"

name = "my-vpc"
cidr = "10.0.0.0/16"
}

For private registries (Terraform Cloud), push modules via the API or VCS integration.

Real-World Module Structure

Here is a production module structure that scales:

modules/
vpc/
main.tf # Core resources
variables.tf # Input interface
outputs.tf # Output interface
versions.tf # Required providers and versions
locals.tf # Computed values
data.tf # Data sources
README.md # Usage documentation
tests/
basic.tftest.hcl
custom_cidr.tftest.hcl
examples/
simple/
main.tf
multi-az/
main.tf

Every module should have examples that demonstrate usage. These examples double as integration tests and documentation.

Anti-Patterns to Avoid

The God Module — one module that creates an entire environment (VPC, subnets, security groups, EC2 instances, RDS, S3, IAM roles). It has 60 variables, 40 outputs, and takes 15 minutes to plan. Break it into composable pieces.

Too Many Variables — if a module has 30+ variables, it is too flexible. Most callers will use the defaults, and the few who need customization are drowning in options. Provide sensible defaults and let advanced users fork the module.

Nested Provider Configurations — never define provider blocks inside modules. Pass providers from the root module instead:

# BAD — provider inside module
# modules/s3/main.tf
provider "aws" {
region = var.region # This causes conflicts with root providers
}

# GOOD — provider passed from root
# root/main.tf
provider "aws" {
alias = "us_west"
region = "us-west-2"
}

module "s3_west" {
source = "./modules/s3"
providers = {
aws = aws.us_west
}
}

Hardcoded Values — a module that works only in us-east-1 or only with t3.medium instances is not reusable. Parameterize anything environment-specific.

Closing Notes

Good module design follows the same principles as good API design: clear interfaces, sensible defaults, minimal surface area, and no surprises. Start with small, atomic modules. Compose them into higher-level patterns. Version everything with Git tags. And resist the temptation to build the one module that does everything — it will do nothing well. In the next post, we will compare Terraform Cloud against self-managed setups — remote execution, governance, team management, and when the paid features are worth it.