Overview

Last year I was setting up an IaC system for an e-commerce client. When I took over, their Terraform codebase looked roughly like this: three environments (dev/staging/prod), each with its own copy of the configuration, totaling over 3,000 lines—90% of which was copy-paste. Changing a VPC CIDR meant editing three files in sync. Miss one, and you get environment drift. The worst part: a security group rule was missing in prod for three months without anyone noticing.

Anyone who has used Terraform has experienced this pain. Modularization is the公认 answer—but many teams find that after modularizing, the code is shorter but maintainability is worse. A module with 40 variables and 3 layers of nesting—changing one parameter requires flipping through three files to understand the blast radius. This is the classic symptom of “turning modules into black boxes.”

This article breaks down a Terraform module layered architecture that I’ve validated across multiple enterprise client projects, plus 6 anti-patterns I’ve genuinely hit in production. Not a generic “best practices” list—every decision point comes with “why I did it this way” and “when not to do it this way.”

Why Modularization Matters

Let’s be precise about what problems modularization solves. Not just “code reuse” in vague terms, but three specific pain points:

Pain point 1: Environment drift. Three environments should have identical configurations, but manual copying inevitably introduces errors. In an出行 project, we counted: copy-pasted configurations averaged 3-5 subtle differences per environment (CIDR shifts, inconsistent availability zone counts, missing security group rules). These differences don’t blow up normally—but when a failure hits, they turn root cause analysis into a nightmare.

Pain point 2: Change synchronization cost. Changing one AMI ID means editing it in 3 environments. If 5 team members are simultaneously modifying different modules, merge conflicts become frequent enough to make you question your career choices.

Pain point 3: Knowledge transfer breakdown. A new team member looks at an 800-line main.tf and has no idea which resources are related, which changes affect what. Without module boundaries, there are no cognitive anchors.

The essence of modularization isn’t “making code shorter”—it’s establishing clear abstraction boundaries. Each module becomes a unit that can be independently understood, tested, and changed.

Module Layered Architecture Design

The Three-Layer Model

My recommended three-layer architecture:

LayerResponsibilityExampleVariable CountResource Count
Base moduleEncapsulates a single cloud resourcemodules/vpc, modules/rds5-153-8
Composition moduleCombines multiple base modulesmodules/eks-platform15-30Calls 3-5 base modules
Environment layerDefines environment-specific configenvs/prod/UnlimitedCalls 2-4 composition modules

These three layers aren’t a new concept, but many people mix them up in practice. The most common mistake: base modules containing composition logic, or environment layers calling base modules directly and skipping the composition layer. Let me break down each layer.

Base Module: Single-Responsibility Building Blocks

The core principle of base modules is single responsibility: one module manages one type of resource. modules/vpc only manages VPC and subnets. modules/rds only manages database instances. Sounds simple, but I’ve seen too many violations:

# Anti-pattern: one module manages VPC + RDS + Security Group + IAM
module "everything" {
  source = "./modules/everything"
  vpc_cidr         = "10.0.0.0/16"
  rds_engine       = "postgres"
  rds_instance_class = "db.r5.large"
  # ... plus 38 more variables
}

Changing one RDS parameter in this module might trigger a VPC rebuild plan because Terraform’s dependency graph couples unrelated resources together.

A correct base module looks like this:

# modules/vpc/main.tf
# Only manages VPC, subnets, route tables, NAT gateways—nothing else

resource "aws_vpc" "this" {
  cidr_block           = var.cidr_block
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = merge(
    local.common_tags,
    { Name = "${var.name_prefix}-vpc" }
  )
}

resource "aws_subnet" "private" {
  for_each = toset(var.private_subnet_cidrs)

  vpc_id            = aws_vpc.this.id
  cidr_block        = each.value
  availability_zone = var.availability_zones[tonumber(regex("[0-9]+$", each.value)) % length(var.availability_zones)]

  tags = merge(
    local.common_tags,
    { Name = "${var.name_prefix}-private-${each.key}", Tier = "private" }
  )
}

resource "aws_route_table" "private" {
  for_each = aws_subnet.private

  vpc_id = aws_vpc.this.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.this[each.key].id
  }

  tags = merge(local.common_tags, { Name = "${var.name_prefix}-rt-private-${each.key}" })
}

Keep base module variables to 5-15. Beyond 15, start questioning—chances are the responsibility has expanded.

My rule of thumb: If a module’s variable descriptions contain more than 3 “when X is true, enable Y resource” patterns, the module is doing too much. Split it.

Composition Module: Business Logic Assembly Layer

Composition modules assemble multiple base modules according to business logic. For example, modules/eks-platform combines VPC + EKS cluster + IAM roles + security groups into a complete K8s platform.

# modules/eks-platform/main.tf
# Combines VPC + EKS + node groups, but doesn't redefine these resources

module "vpc" {
  source = "../vpc"

  cidr_block          = var.vpc_cidr
  name_prefix         = var.name_prefix
  availability_zones  = var.availability_zones
  private_subnet_cidrs = var.private_subnet_cidrs
  public_subnet_cidrs  = var.public_subnet_cidrs
}

module "eks" {
  source = "../eks"

  cluster_name       = "${var.name_prefix}-cluster"
  kubernetes_version = var.kubernetes_version
  vpc_id             = module.vpc.vpc_id
  subnet_ids         = module.vpc.private_subnet_ids
  node_groups        = var.node_groups
}

# Outputs for the environment layer
output "cluster_endpoint" {
  value = module.eks.cluster_endpoint
}

output "vpc_id" {
  value = module.vpc.vpc_id
}

Key design principle for composition modules: assemble only, don’t define resources. No resource blocks should appear in composition modules—all resources are defined in base modules. Composition modules only handle module calls and parameter passing.

This principle saved me in a real project. A client wanted to add an extra security group rule to their EKS cluster and directly added a resource "aws_security_group_rule" in the eks-platform composition module. When the EKS module was upgraded, that security group rule became orphaned—Terraform state had the resource, but no module claimed it, resulting in state drift.

Environment Layer: The Differentiation Entry Point

The environment layer is the top level. Each environment gets its own directory and only defines that environment’s specific configuration:

infrastructure/
├── modules/           # Base modules
   ├── vpc/
   ├── eks/
   ├── rds/
   └── alb/
├── compositions/      # Composition modules
   ├── eks-platform/
   └── web-stack/
├── envs/              # Environment layer
   ├── dev/
      ├── main.tf
      ├── variables.tf
      └── terraform.tfvars
   ├── staging/
      ├── main.tf
      ├── variables.tf
      └── terraform.tfvars
   └── prod/
       ├── main.tf
       ├── variables.tf
       └── terraform.tfvars
└── README.md

The environment layer’s main.tf is extremely concise:

# envs/prod/main.tf
terraform {
  required_version = ">= 1.5.0"
  backend "s3" {
    bucket         = "mycompany-tfstate-prod"
    key            = "eks-platform/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "tf-locks-prod"
    encrypt        = true
  }
}

module "platform" {
  source = "../../compositions/eks-platform"

  name_prefix         = "prod"
  vpc_cidr            = "10.10.0.0/16"
  availability_zones  = ["us-east-1a", "us-east-1b", "us-east-1c"]
  kubernetes_version  = "1.30"
  node_groups = {
    general = {
      instance_types = ["m5.large", "m5.xlarge"]
      min_size       = 3
      max_size       = 10
      desired_size   = 5
    }
    spot = {
      instance_types = ["m5.large"]
      min_size       = 0
      max_size       = 20
      desired_size   = 5
      capacity_type  = "spot"
    }
  }
}

Notice: no resource blocks in the environment layer—only module calls and parameter assignments. All logic lives in modules; the environment layer only handles “what parameters does this environment use.”

For Terraform State backend configuration, this shows the basic structure only. Production state file management involves a complete disaster recovery approach—see Related: Lost Your State File? Terraform State Disaster Recovery and Production-Grade Backend Architecture for details.

Variable and Output Design

Three Iron Rules for Variables

Rule 1: Every variable must have a type and description.

# Correct
variable "node_groups" {
  type = map(object({
    instance_types = list(string)
    min_size       = number
    max_size       = number
    desired_size   = number
    capacity_type  = optional(string, "ON_DEMAND")
  }))
  description = "Map of node group configurations. Key is the group name."
}

# Wrong: no type, no description
variable "node_groups" {
  default = {}
}

This isn’t a style issue—it’s an engineering issue. Variables without type constraints accept any input. Pass the wrong type accidentally, and terraform plan won’t error but apply will explode at 2 AM. You don’t want to deal with that.

Rule 2: Provide safe defaults.

variable "enable_deletion_protection" {
  type    = bool
  default = true  # Production-safe default
  description = "Whether to enable deletion protection for resources that support it."
}

Design principle for defaults: defaults must be production-safe. enable_deletion_protection defaults to true. Dev environments explicitly set it to false. This way, even if someone forgets to configure it, production won’t lose protection from accidental deletion.

Rule 3: Split when variables exceed 15.

I set a hard metric in real projects: base modules ≤ 15 variables, composition modules ≤ 30. Beyond that, split. This isn’t an arbitrary number—it’s the cognitive load threshold I’ve observed across multiple projects. A module with 20+ variables takes 3x longer for a newcomer to understand compared to a 15-variable module.

Output Value Standards

Output values are the module’s “interface contract.” Design principle: output what consumers need, not what the module internally implements.

# Correct: output identifiers consumers need
output "vpc_id" {
  value       = aws_vpc.this.id
  description = "The ID of the VPC."
}

output "private_subnet_ids" {
  value       = [for s in aws_subnet.private : s.id]
  description = "List of private subnet IDs."
}

# Wrong: output internal implementation details
output "route_table_private_ids" {
  value = [for rt in aws_route_table.private : rt.id]
}

Route table IDs are internal implementation of the VPC module—unless there’s a clear consumer needing them, don’t expose them. Every additional output is an additional coupling point: when you refactor the module’s route table implementation, everything depending on that output needs to change too.

Sensitive Data Handling

variable "db_password" {
  type      = string
  sensitive = true
  description = "Master password for the RDS instance."
}

output "db_endpoint" {
  value     = aws_db_instance.this.endpoint
  sensitive = true  # Mark as sensitive if endpoint contains sensitive info
}

Variables marked sensitive = true are displayed as (sensitive value) in terraform plan and terraform apply output. But note: this only masks at the CLI output level—the state file still stores values in plaintext. True sensitive data protection requires Vault or KMS integration—see Related: Deploying IaC Without Tests? A Four-Layer Validation Pipeline That Cuts 3AM Alerts by 80% for a detailed security validation approach.

Module Version Management

Local Modules vs Registry Modules

This choice directly impacts team iteration speed.

AspectLocal ModulesRegistry Modules
Change speedFast, change and use immediatelySlow, requires release + update reference
Version controlDepends on Git repoIndependent version numbers
Reuse scopeWithin single repoCross-repo, cross-team
Best forRapid iteration phaseStable sharing phase

My recommendation: Use local modules exclusively during the first 3 months (project initial phase) for rapid iteration and experimentation. Once modules stabilize, extract high-frequency reusable modules to independent Git repos for versioned Registry references.

This strategy comes from real lessons. One client started with Registry modules from day one. In the first two months, they modified modules 40 times—each time requiring: modify module repo → tag → update source version in consumers → re-run terraform init. Iteration speed dropped by 3x.

Version Locking Strategy

# Recommended: semantic version constraints
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"  # Allow 5.x patch updates
}

# More conservative
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.1.2"  # Lock exact version, manual upgrades only
}

# Not recommended: no version lock
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  # No version field, pulls latest
}

~> 5.0 allows 5.x updates but no major version bumps. This strategy balances security and convenience: bugfixes in patch versions come automatically, major version upgrades require human review.

For more Terraform basics, see Related: Getting Started with Terraform Infrastructure as Code.

Multi-Environment Reuse in Practice

The Right Way to Handle Environment Differentiation

Multi-environment reuse is the most direct value of modularization. Core principle: module logic doesn’t change, only parameters change.

# envs/dev/terraform.tfvars
name_prefix        = "dev"
vpc_cidr           = "10.20.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b"]
node_groups = {
  general = {
    instance_types = ["t3.medium"]
    min_size       = 1
    max_size       = 3
    desired_size   = 1
  }
}

# envs/prod/terraform.tfvars
name_prefix        = "prod"
vpc_cidr           = "10.10.0.0/16"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
node_groups = {
  general = {
    instance_types = ["m5.large", "m5.xlarge"]
    min_size       = 3
    max_size       = 10
    desired_size   = 5
  }
  spot = {
    instance_types = ["m5.large"]
    min_size       = 0
    max_size       = 20
    desired_size   = 5
    capacity_type  = "spot"
  }
}

Dev uses 2 availability zones, t3.medium, minimum 1 node. Prod uses 3 availability zones, m5.large, minimum 3 nodes plus a Spot pool. Logic is identical—only parameters differ.

Terragrunt vs Native Terraform

As environment count grows, native Terraform’s pain points emerge: each environment directory needs its own terraform block (backend config) and provider block, causing code duplication. Terragrunt solves this:

# envs/prod/terragrunt.hcl
include "root" {
  path = find_in_parent_folders("root.hcl")
}

terraform {
  source = "../../compositions/eks-platform"
}

inputs = {
  name_prefix        = "prod"
  vpc_cidr           = "10.10.0.0/16"
  availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
  kubernetes_version = "1.30"
}

# envs/root.hcl - shared configuration
remote_state {
  backend = "s3"
  config = {
    bucket         = "mycompany-tfstate"
    key            = "${path_relative_to_include()}/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "tf-locks"
    encrypt        = true
  }
}

Terragrunt’s find_in_parent_folders automatically finds shared config. path_relative_to_include() makes the state key auto-differentiate by environment name. Backend config is written once.

My choice: For 5 or fewer environments, native Terraform is sufficient. Beyond that, use Terragrunt. Terragrunt isn’t a silver bullet—it introduces an additional toolchain and learning curve. In a logistics platform project with only 3 environments, we managed perfectly fine with native Terraform + tfvars files. No need to add Terragrunt complexity.

Performance Comparison Data

This layered architecture produced the following comparison data from a real client deployment:

MetricBefore ModularizationAfter ModularizationChange
Total lines of code3,200 lines850 lines↓73%
Environment deployment time25 min8 min↓68%
Change sync costEdit 3 filesEdit 1 tfvars↓67%
Config-error incidents2-3/month1/quarter↓80%
New member onboarding2 weeks3 days↓78%

The 73% code reduction came from eliminating copy-paste, not removing features. The 68% deployment time improvement is partly because modularization produces a cleaner dependency graph, allowing Terraform to parallelize unrelated resources more effectively.

6 Production Anti-Patterns and Fixes

These anti-patterns aren’t copied from articles—each one comes from real production experience.

Anti-Pattern 1: Over-Abstraction

Symptom: A base module tries to cover every scenario, stuffed with enable_xxx toggles.

# Anti-pattern
module "vpc" {
  source = "./modules/vpc"
  enable_vpc          = true
  enable_nat_gateway   = true
  enable_flow_log      = true
  enable_vpn_gateway   = false
  enable_dns           = true
  enable_ipv6          = false
  # ... plus 20 more enable_xxx
}

Why it’s a problem: The module’s terraform plan output becomes a combinatorial explosion of if-else branches. To understand “what resources does this module actually create,” you need to trace a dozen toggle states.

Fix: Split into independent modules. NAT gateway, Flow Log, VPN Gateway each get their own module, referenced as needed.

# Fixed
module "vpc" {
  source = "./modules/vpc"
  # Only manages VPC and subnets
  cidr_block = "10.0.0.0/16"
}

module "nat" {
  source     = "./modules/nat-gateway"
  subnet_ids = module.vpc.public_subnet_ids
  # Reference only when needed; omit this block entirely if not needed
}

Anti-Pattern 2: Variable Explosion

Symptom: Composition modules expose all underlying module variables, causing variable count to spiral out of control.

# Anti-pattern: composition module transparently passes through all variables
module "vpc" {
  source = "../vpc"
  cidr_block                    = var.vpc_cidr_block
  enable_dns_support            = var.vpc_enable_dns_support
  enable_dns_hostnames          = var.vpc_enable_dns_hostnames
  instance_tenancy             = var.vpc_instance_tenancy
  # Passing through all 15 variables of the VPC module
}

Why it’s a problem: The composition module becomes a “pipe”—consumers need to understand all underlying module variables. Variable count goes from 15 + 20 + 10 = 45, all dumped on the composition module’s interface.

Fix: Composition modules should collapse parameters, exposing only business-relevant ones.

# Fixed: composition module exposes only business parameters
module "vpc" {
  source = "../vpc"

  cidr_block = var.vpc_cidr
  # Other parameters use base module defaults, no passthrough
}

Rule of thumb: If a composition module’s variable count exceeds 30, check whether it’s transparently passing through underlying variables. Beyond 50, refactor immediately.

Anti-Pattern 3: Circular Dependencies

Symptom: Module A depends on module B’s output, and module B depends on module A’s output.

This is particularly common between VPC and security groups: the VPC module needs a security group ID for default route rules, and the security group module needs the VPC ID for ownership.

Fix: Extract shared resources to the upper layer.

# Fix: environment layer creates VPC first, then passes to both security group and routing
module "vpc" {
  source = "../../modules/vpc"
  cidr_block = var.vpc_cidr
}

module "security_group" {
  source  = "../../modules/security-group"
  vpc_id  = module.vpc.vpc_id
  rules   = var.security_group_rules
}

# VPC module doesn't do security group associations internally

Anti-Pattern 4: Ignoring Module Version Compatibility

Symptom: Local modules use source = "../modules/vpc" directly. Changing a module affects everyone—no version isolation.

Why it’s a problem: You add a force_destroy parameter to the VPC module, tested in dev, but prod’s configuration isn’t compatible with this parameter. Without version isolation, prod’s next terraform init pulls the incompatible version.

Fix: Use Git tags for version isolation.

# Fix: lock version with Git ref
module "vpc" {
  source = "git::https://git.mycompany.com/infra/terraform-modules.git//vpc?ref=v1.2.0"

  cidr_block = var.vpc_cidr
}

Dev uses ref=v1.3.0-rc1, prod locks ref=v1.2.0. Upgrade by first validating in dev, then updating prod’s version number.

Anti-Pattern 5: State File Coupling

Symptom: Multiple modules share the same state file. One module’s terraform destroy affects other modules’ resources.

Why it’s a problem: The state file is Terraform’s “ledger.” All modules in the same state means all modules share one lock—while one module is applying, all others wait. And terraform destroy might accidentally remove too much.

Fix: Use independent state files per environment layer.

# envs/prod/main.tf - independent state key
terraform {
  backend "s3" {
    bucket = "mycompany-tfstate-prod"
    key    = "eks-platform/terraform.tfstate"  # This key is independent
    region = "us-east-1"
  }
}

Different modules use different state keys, no interference. The complete state management approach is detailed in Related: Terraform State Disaster Recovery and Production-Grade Backend Architecture.

Anti-Pattern 6: Deploying Modules to Production Without Tests

Symptom: Modules go straight to prod with terraform apply. Problems get fixed manually in state.

Why it’s a problem: Terraform modules are code—deploying without tests is flying blind. A variable type error or missing dependency can cause apply to fail midway, leaving half-created resources.

Fix: Use terraform test (Terraform 1.6+) or terratest for module testing.

# tests/vpc_test.tftest.hcl
run "vpc_creation" {
  command = plan

  variables {
    cidr_block = "10.0.0.0/16"
    name_prefix = "test"
    availability_zones = ["us-east-1a", "us-east-1b"]
  }

  assert {
    condition     = output.vpc_cidr_block == "10.0.0.0/16"
    error_message = "VPC CIDR block does not match input"
  }

  assert {
    condition     = length(output.private_subnet_ids) == 2
    error_message = "Should create 2 private subnets"
  }
}

Run terraform test in CI. Module changes that don’t pass tests can’t merge to main. For a complete IaC testing system design, see Related: Deploying IaC Without Tests? A Four-Layer Validation Pipeline That Cuts 3AM Alerts by 80%.

Production Environment Notes

Capacity assessment: Modularization doesn’t change resource count—only management approach. Before implementation, count resources per environment. Beyond 500 resources, a single state file’s terraform plan takes over 5 minutes—consider splitting state.

Rollback strategy: If apply fails after a module change, Terraform doesn’t auto-rollback (that’s not its design goal). Rollback strategy: use version control to revert module code → terraform apply to reconcile actual state to the old configuration. Not terraform state rollback—Terraform doesn’t have that command.

Team collaboration: After modularization, module owners must be explicit. Recommend assigning an owner to each base module—changes require owner review. Modules without owners gradually become “orphan code”—nobody dares to touch them, eventually becoming technical debt.

Toolchain selection: Here’s my toolchain recommendation by project scale:

Project ScaleRecommended ToolchainRationale
≤3 environmentsNative Terraform + tfvarsSimple toolchain, sufficient
5-10 environmentsTerragrunt + local modulesReduces config duplication, keeps local module iteration speed
10+ environmentsTerragrunt + Registry modules + Atlantis/TFCVersion isolation + PR-driven automated plan
Cross-team sharingTerraform Cloud/EnterpriseUnified module registry + RBAC + audit logs

Summary

The core of modular IaC isn’t “splitting code”—it’s “establishing abstraction boundaries.” The three-layer architecture (base module → composition module → environment layer) provides clear boundary划分: base modules manage single resources, composition modules manage business assembly, environment layers manage differentiated parameters.

The common root cause of all 6 anti-patterns is blurred boundaries: over-abstraction is base module boundary expansion, variable explosion is composition module interface失控, circular dependency is cross-module boundary intersection, ignoring versions is missing evolution isolation, state coupling is missing runtime boundaries, no testing is missing quality boundaries.

From real-world data, this architecture reduced code by 73%, shortened deployment time by 68%, and cut configuration-error incidents by 80%. But numbers are just results—the real value is: each module becomes a unit that can be independently understood, tested, and changed. New members onboard in 3 days. Changing one parameter doesn’t require flipping through three files.

If you only remember one sentence: modules are written for humans to read, not for Terraform to run. A module that Terraform can execute but humans can’t understand is a failed design.

References & Acknowledgments

The following resources were referenced during the writing of this article. Thanks to the original authors for their contributions:

  1. Terraform Best Practices — terraform-best-practices.com, provided community practice references for code structure, naming conventions, and module design
  2. Terraform v1.x Compatibility Promises — HashiCorp official documentation, defines Terraform 1.x compatibility commitments, basis for module version management strategy
  3. Architecture strategies for using infrastructure as code — Microsoft Azure Well-Architected Framework, provided IaC maturity model and design pattern references
  4. Terraform 模块化设计: 如何构建可复用、可组合的基础设施代码 — Jianshu tech blog, referenced modular design principles and code reuse rate data
  5. Terragrunt 常见问题解答 — CSDN tech blog, referenced Terragrunt’s DRY configuration reuse mechanism and multi-environment management approach