# Terraform Module Design Patterns Reference ## Pattern 1: Flat Module (Single Directory) Best for: Small projects, < 20 resources, single team ownership. ``` project/ ├── main.tf ├── variables.tf ├── outputs.tf ├── versions.tf ├── locals.tf ├── backend.tf └── terraform.tfvars ``` ### Example: Simple VPC + EC2 ```hcl # versions.tf terraform { required_version = ">= 1.5.0" required_providers { aws = { source = "hashicorp/aws" version = "~> 5.0" } } } # locals.tf locals { name_prefix = "${var.project}-${var.environment}" common_tags = { Project = var.project Environment = var.environment ManagedBy = "terraform" } } # main.tf resource "aws_vpc" "main" { cidr_block = var.vpc_cidr enable_dns_hostnames = true enable_dns_support = true tags = merge(local.common_tags, { Name = "${local.name_prefix}-vpc" }) } resource "aws_subnet" "public" { count = length(var.public_subnet_cidrs) vpc_id = aws_vpc.main.id cidr_block = var.public_subnet_cidrs[count.index] availability_zone = var.availability_zones[count.index] tags = merge(local.common_tags, { Name = "${local.name_prefix}-public-${count.index + 1}" Tier = "public" }) } # variables.tf variable "project" { description = "Project name used for resource naming" type = string } variable "environment" { description = "Deployment environment" type = string validation { condition = contains(["dev", "staging", "prod"], var.environment) error_message = "Environment must be dev, staging, or prod." } } variable "vpc_cidr" { description = "CIDR block for the VPC" type = string default = "10.0.0.0/16" validation { condition = can(cidrhost(var.vpc_cidr, 0)) error_message = "Must be a valid CIDR block." } } variable "public_subnet_cidrs" { description = "CIDR blocks for public subnets" type = list(string) default = ["10.0.1.0/24", "10.0.2.0/24"] } variable "availability_zones" { description = "AZs for subnet placement" type = list(string) default = ["us-east-1a", "us-east-1b"] } # outputs.tf output "vpc_id" { description = "ID of the created VPC" value = aws_vpc.main.id } output "public_subnet_ids" { description = "IDs of public subnets" value = aws_subnet.public[*].id } ``` --- ## Pattern 2: Nested Modules (Composition) Best for: Multiple environments, shared patterns, team collaboration. ``` infrastructure/ ├── environments/ │ ├── dev/ │ │ ├── main.tf │ │ ├── backend.tf │ │ └── terraform.tfvars │ ├── staging/ │ │ └── ... │ └── prod/ │ └── ... └── modules/ ├── networking/ │ ├── main.tf │ ├── variables.tf │ └── outputs.tf ├── compute/ │ └── ... └── database/ └── ... ``` ### Root Module (environments/dev/main.tf) ```hcl module "networking" { source = "../../modules/networking" project = var.project environment = "dev" vpc_cidr = "10.0.0.0/16" public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"] private_subnet_cidrs = ["10.0.10.0/24", "10.0.11.0/24"] } module "compute" { source = "../../modules/compute" project = var.project environment = "dev" vpc_id = module.networking.vpc_id subnet_ids = module.networking.private_subnet_ids instance_type = "t3.micro" instance_count = 1 } module "database" { source = "../../modules/database" project = var.project environment = "dev" vpc_id = module.networking.vpc_id subnet_ids = module.networking.private_subnet_ids instance_class = "db.t3.micro" allocated_storage = 20 db_password = var.db_password } ``` ### Key Rules - Child modules never call other child modules - Pass values explicitly — no hidden data source lookups in children - Provider configuration only in root module - Each module has its own variables.tf, outputs.tf, main.tf --- ## Pattern 3: Registry Module Pattern Best for: Reusable modules shared across teams or organizations. ``` terraform-aws-vpc/ ├── main.tf ├── variables.tf ├── outputs.tf ├── versions.tf ├── README.md ├── examples/ │ ├── simple/ │ │ └── main.tf │ └── complete/ │ └── main.tf └── modules/ ├── subnet/ │ ├── main.tf │ ├── variables.tf │ └── outputs.tf └── nat-gateway/ └── ... ``` ### Publishing Conventions ```hcl # Consumer usage module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "~> 5.0" name = "my-vpc" cidr = "10.0.0.0/16" azs = ["us-east-1a", "us-east-1b"] private_subnets = ["10.0.1.0/24", "10.0.2.0/24"] public_subnets = ["10.0.101.0/24", "10.0.102.0/24"] enable_nat_gateway = true single_nat_gateway = true } ``` ### Registry Module Requirements - Repository named `terraform--` - README.md with usage examples - Semantic versioning via git tags - examples/ directory with working configurations - No provider configuration in the module itself --- ## Pattern 4: Mono-Repo with Workspaces Best for: Teams that prefer single-repo with workspace-based isolation. ```hcl # backend.tf terraform { backend "s3" { bucket = "my-terraform-state" key = "project/terraform.tfstate" region = "us-east-1" dynamodb_table = "terraform-locks" encrypt = true } } # main.tf locals { env_config = { dev = { instance_type = "t3.micro" instance_count = 1 db_class = "db.t3.micro" } staging = { instance_type = "t3.small" instance_count = 2 db_class = "db.t3.small" } prod = { instance_type = "t3.large" instance_count = 3 db_class = "db.r5.large" } } config = local.env_config[terraform.workspace] } ``` ### Usage ```bash terraform workspace new dev terraform workspace new staging terraform workspace new prod terraform workspace select dev terraform apply terraform workspace select prod terraform apply ``` ### Workspace Caveats - All environments share the same backend — less isolation than separate directories - A mistake in the code affects all environments - Can't have different provider versions per workspace - Recommended only for simple setups; prefer separate directories for production --- ## Pattern 5: for_each vs count ### Use `count` for identical resources ```hcl resource "aws_subnet" "public" { count = 3 vpc_id = aws_vpc.main.id cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index) availability_zone = data.aws_availability_zones.available.names[count.index] } ``` ### Use `for_each` for distinct resources ```hcl variable "buckets" { type = map(object({ versioning = bool lifecycle_days = number })) default = { logs = { versioning = false, lifecycle_days = 30 } backups = { versioning = true, lifecycle_days = 90 } assets = { versioning = true, lifecycle_days = 0 } } } resource "aws_s3_bucket" "this" { for_each = var.buckets bucket = "${var.project}-${each.key}" } resource "aws_s3_bucket_versioning" "this" { for_each = { for k, v in var.buckets : k => v if v.versioning } bucket = aws_s3_bucket.this[each.key].id versioning_configuration { status = "Enabled" } } ``` ### Why `for_each` > `count` - `count` uses index — removing item 0 shifts all others, causing destroy/recreate - `for_each` uses keys — removing a key only affects that resource - Use `count` only for identical resources where order doesn't matter --- ## Variable Design Patterns ### Object Variables for Related Settings ```hcl variable "database" { description = "Database configuration" type = object({ engine = string instance_class = string storage_gb = number multi_az = bool backup_days = number }) default = { engine = "postgres" instance_class = "db.t3.micro" storage_gb = 20 multi_az = false backup_days = 7 } } ``` ### Validation Blocks ```hcl variable "instance_type" { description = "EC2 instance type" type = string validation { condition = can(regex("^t[23]\\.", var.instance_type)) error_message = "Only t2 or t3 instance types are allowed." } } variable "cidr_block" { description = "VPC CIDR block" type = string validation { condition = can(cidrhost(var.cidr_block, 0)) error_message = "Must be a valid IPv4 CIDR block." } } ``` --- ## Anti-Patterns to Avoid | Anti-Pattern | Problem | Solution | |-------------|---------|----------| | God module (100+ resources) | Impossible to reason about, slow plan/apply | Split into focused child modules | | Circular module dependencies | Terraform can't resolve dependency graph | Flatten or restructure module boundaries | | Data sources in child modules | Hidden dependencies, hard to test | Pass values as variables from root module | | Provider config in child modules | Can't reuse module across accounts/regions | Configure providers in root only | | Hardcoded values | Not reusable across environments | Use variables with defaults and validation | | No outputs | Consumer modules can't reference resources | Output IDs, ARNs, endpoints | | No variable descriptions | Users don't know what to provide | Every variable gets a description | | `terraform.tfvars` committed | Secrets leak to version control | Use `.gitignore`, env vars, or Vault |