Anti-Pattern: Over-Complex Module #

A good module hides implementation complexity and provides a simple interface. A bad module hides simplicity behind unnecessary abstraction, forcing users to learn that abstraction before they can use it. The over-complex module is one of the most common anti-patterns found in mature Terraform repositories — starting with good intentions to make a “flexible” module, ending with a module harder to understand than the resources it wraps.

flowchart LR
    subgraph Good["Good Module ✅"]
        A["Simple\ninterface"] --> B["Hidden\ncomplexity"]
    end
    subgraph Bad["Over-Complex ❌"]
        C["Complex\ninterface"] --> D["Simple\ninside"]
    end

    style A fill:#10b981,stroke:#059669,color:#fff
    style B fill:#10b981,stroke:#059669,color:#fff
    style C fill:#ef4444,stroke:#dc2626,color:#fff
    style D fill:#ef4444,stroke:#dc2626,color:#fff

Signs of an Overly Complex Module #

# ANTI-PATTERN: A module with dozens of variables

# variables.tf — a module already out of control
variable "enable_monitoring"         { type = bool; default = true }
variable "enable_logging"            { type = bool; default = true }
variable "enable_enhanced_logging"   { type = bool; default = false }
variable "enable_debug_logging"      { type = bool; default = false }
variable "log_retention_days"        { type = number; default = 30 }
variable "log_format"                { type = string; default = "json" }
variable "enable_metrics"            { type = bool; default = true }
variable "metrics_namespace"         { type = string; default = "" }
variable "enable_alarms"             { type = bool; default = false }
variable "alarm_email"               { type = string; default = "" }
variable "alarm_threshold_cpu"       { type = number; default = 80 }
variable "alarm_threshold_memory"    { type = number; default = 85 }
variable "alarm_evaluation_periods"  { type = number; default = 3 }
# ... 30 more variables

# Problem: Nobody can know what will happen
# without reading all the module's internal code
SIGNS OF AN OVER-COMPLEX MODULE:

  ✗ More than 15-20 input variables
  ✗ Variables named like enable_feature_x (excessive boolean flags)
  ✗ Complex conditional logic inside the module (count = var.enable_x ? 1 : 0)
  ✗ The module tries to handle every possible use case
  ✗ The module documentation is longer than the configuration using it
  ✗ Module users need to read the source code to understand what it does
  ✗ Hard to write tests because there are too many input combinations

Excessive Abstraction That Hides Clarity #

# ANTI-PATTERN: An "abstract" module that's actually confusing

module "my_ec2" {
  source = "./modules/ec2-abstracted"

  # Users must guess what these parameters mean
  compute_tier     = "standard"     # What is this? Maps to what?
  redundancy_level = 2              # Is this instance count? AZs? Replicas?
  network_exposure = "semi-public"  # What's the difference between public, semi-public, private?
  persistence_mode = "ephemeral"    # Does this have a volume or not?
}

# This abstraction hides the actual configuration —
# users don't know what resources will be created

# CORRECT: A module with a clear interface
module "web_server" {
  source = "./modules/ec2"

  instance_count = 2
  instance_type  = "t3.medium"
  subnet_ids     = var.private_subnet_ids   # Clear: private subnets
  assign_public_ip = false                  # Clear: no public IP
}

# From this interface, users immediately know:
# - 2 EC2 instances will be created
# - In private subnets
# - Without public IPs

Proliferating Boolean Flags #

# ANTI-PATTERN: Too many boolean flags
module "rds" {
  source = "./modules/rds"

  enable_multi_az          = var.env == "production"
  enable_backup            = true
  enable_performance_insight = var.env == "production"
  enable_deletion_protection = var.env == "production"
  enable_enhanced_monitoring = var.env == "production"
  enable_iam_auth          = false
  enable_auto_minor_version_upgrade = true
  # ... every caller must set all these flags

# CORRECT: Provide commonly used presets
module "rds_production" {
  source = "./modules/rds"
  preset = "production"  # The module handles all production options internally

  # Or better yet: provide two separate modules
  # modules/rds-production/  and  modules/rds-development/
  # with the right defaults for each use case
}
# Implementing a preset inside the module
variable "preset" {
  type        = string
  default     = "development"
  description = "Configuration preset: development or production"

  validation {
    condition     = contains(["development", "production"], var.preset)
    error_message = "Preset must be 'development' or 'production'"
  }
}

locals {
  config = {
    development = {
      multi_az                    = false
      backup_retention_period     = 1
      deletion_protection         = false
      performance_insights_enabled = false
    }
    production = {
      multi_az                    = true
      backup_retention_period     = 7
      deletion_protection         = true
      performance_insights_enabled = true
    }
  }[var.preset]
}

Modules Trying to Do Too Much #

# ANTI-PATTERN: A "god module" that does everything
module "application_stack" {
  source = "./modules/application-stack"

  # This module creates:
  # - VPC and networking
  # - ECS cluster
  # - RDS database
  # - ElastiCache
  # - Load balancer
  # - Route53 records
  # - ACM certificates
  # - IAM roles
  # - CloudWatch dashboards
  # - SNS topics for alerting
  # All in one module!

  # Problem: Can't be used partially,
  # can't be updated partially, very large blast radius
}

# CORRECT: One module, one responsibility
module "networking" {
  source = "./modules/networking"
  # Only VPC, subnets, routing
}

module "database" {
  source    = "./modules/rds"
  vpc_id    = module.networking.vpc_id
  subnet_ids = module.networking.private_subnet_ids
}

module "application" {
  source     = "./modules/ecs-service"
  cluster_id = module.compute.cluster_id
  db_endpoint = module.database.endpoint
}

# Each module can be updated, tested, and scaled independently

Good Module Interface Principles #

# A good interface: minimal, clear, with sensible defaults

variable "name" {
  type        = string
  description = "Resource name — used as a prefix for all resources created"
}

variable "environment" {
  type        = string
  description = "Environment: dev, staging, or production"
  validation {
    condition     = contains(["dev", "staging", "production"], var.environment)
    error_message = "Environment must be dev, staging, or production"
  }
}

variable "vpc_id" {
  type        = string
  description = "ID of the VPC where resources will be created"
}

variable "subnet_ids" {
  type        = list(string)
  description = "List of subnet IDs where resources will be distributed"
}

# Additional options with sensible defaults
variable "instance_type" {
  type        = string
  description = "EC2 instance type"
  default     = "t3.medium"
}

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

# NOT needed:
# variable "enable_feature_x" — this should be part of the module logic
# variable "internal_timeout_seconds" — implementation detail, not an interface
# variable "advanced_config_object" — a sign the module is already overloaded

Refactoring an Already-Complex Module #

REFACTORING STRATEGY:

  Step 1: Identify functional groups
    From 40 variables, group them into: networking, compute, monitoring, security
    Each group that can stand alone → a candidate for a separate module

  Step 2: Separate "core" from "optional"
    Core: variables almost always set by all callers
    Optional: variables used by <50% of callers
    → Optional features can become separate optional modules

  Step 3: Use moved blocks for refactoring without destruction
    When splitting a module, use moved blocks so resources
    don't need to be destroyed and recreated

  Step 4: Deprecation period
    Keep the old module around temporarily
    Add a warning in the description that the module is deprecated
    Give callers time to migrate to the new module

flowchart TD
    A["Module size?"] -->|"Few resources"| B["Inline\nbetter"]
    A -->|"Many resources"| C["Module"]
    C --> D{"Many\ninputs?"}
    D -->|"Yes"| E["Too complex\nsimplify"]
    D -->|"No"| F["Good module ✅"]

    style A fill:#8b5cf6,stroke:#6d28d9,color:#fff
    style B fill:#10b981,stroke:#059669,color:#fff
    style C fill:#3b82f6,stroke:#1e40af,color:#fff
    style D fill:#f59e0b,stroke:#d97706,color:#fff
    style E fill:#ef4444,stroke:#dc2626,color:#fff
    style F fill:#10b981,stroke:#059669,color:#fff

Module Refactoring Strategy #

# When a module is already too complex:
# 1. Identify sub-components that can be separated
# 2. Create a child module for each sub-component
# 3. The parent module becomes an orchestrator only
# BEFORE: Everything in one module
module "app" {
  source = "./modules/app-monolith"
  # 50+ variables, 30+ resources
}

# AFTER: Split into sub-modules
module "networking" {
  source = "./modules/networking"
}

module "compute" {
  source = "./modules/compute"
  vpc_id = module.networking.vpc_id
}

module "database" {
  source = "./modules/database"
  subnet_ids = module.networking.private_subnet_ids
}

Module Interface Documentation #

# Every module should have a README with documentation:

# Module: networking
# Description: Creates a VPC and networking components
#
# Required Variables:
#   - vpc_cidr (string): CIDR block for the VPC
#   - environment (string): Environment name
#
# Optional Variables:
#   - enable_nat_gateway (bool, default: true)
#   - single_nat_gateway (bool, default: false)
#
# Outputs:
#   - vpc_id (string): ID of the VPC
#   - private_subnet_ids (list): IDs of the private subnets
#   - public_subnet_ids (list): IDs of the public subnets
#
# Usage:
#   module "networking" {
#     source     = "./modules/networking"
#     vpc_cidr   = "10.0.0.0/16"
#     environment = "production"
#   }

Summary #

  • A good module has a minimal, clear interface — users don’t need to read the source code to understand what will be created.
  • More than 15-20 variables is a danger sign — the module is likely trying to do too many things at once.
  • Proliferating boolean flags (enable_x, enable_y, enable_z) create endless combinations that are hard to test — replace them with presets or split into separate modules.
  • “God modules” that do everything have a large blast radius and can’t be partially updated — split them into modules with a single responsibility.
  • Abstraction is only useful if it simplifies the interface, not if it forces users to learn a new system as complex as the original resources.
  • When refactoring an already-complex module: identify functional groups, separate core from optional, use moved blocks for migration without destruction.

← Previous: Anti-Pattern: Terraform as CM   Next: Anti-Pattern: Production Failure Scenario →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact