Interface Design #

A good module isn’t just a module that works correctly — it’s a module that’s easy to use correctly and hard to use incorrectly. The module interface is everything: which variables are accepted, which are required, which are optional, what their defaults are, and how granular the control given to callers is. A poorly designed interface forces callers to understand implementation details that should be hidden, or makes them afraid to change values because they’re not sure what the impact will be.

flowchart LR
    A["📋 Interface\nDesign"] --> B["Minimal required\nvariables"]
    A --> C["Sensible\ndefaults"]
    A --> D["Structured\noutputs"]
    A --> E["Clear\ndescriptions"]

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

Principles of a Good Interface #

A GOOD MODULE INTERFACE:
  ✓ Few required variables — only those that truly can't be defaulted
  ✓ Sensible defaults for all optional variables
  ✓ Clear, consistent variable names
  ✓ Descriptions that explain the purpose, not repeat the name
  ✓ Validation that catches invalid values early
  ✓ Enough outputs for all common use cases

A BAD MODULE INTERFACE:
  ✗ Too many required variables — callers have to know too much
  ✗ Variables without defaults that could actually have sensible defaults
  ✗ Ambiguous variable names or names inconsistent with other modules
  ✗ No validation — errors appear at apply time, not plan time
  ✗ Insufficient outputs — callers can't get the values they need
flowchart TD
    A["Consumer Module"] -->|"required vars"| B["📦 Module\nInterface"]
    A -->|"optional vars"| B
    B -->|"outputs"| A
    B -->|"Internal\nimplementation"| C["Hidden\nResources"]

    style A fill:#f59e0b,stroke:#d97706,color:#fff
    style B fill:#3b82f6,stroke:#1e40af,color:#fff
    style C fill:#6b7280,stroke:#4b5563,color:#fff

Required vs Optional Variables #

Only make a variable required (no default) if its value truly can’t be defaulted — because it’s unique per deployment and there’s no “sensible” value to pick.

# variables.tf — good design

# REQUIRED: Values unique per deployment, can't be defaulted
variable "name" {
  description = "Unique name for the resource — used as a prefix on all resources created by this module"
  type        = string
  # No default — every caller must decide this themselves
}

variable "vpc_id" {
  description = "ID of the VPC where resources will be placed"
  type        = string
  # No default — depends on the caller's infrastructure
}

# OPTIONAL: Values with a sensible "industry standard"
variable "instance_type" {
  description = "EC2 instance type for the web server"
  type        = string
  default     = "t3.micro"
  # t3.micro is a sensible choice for light workloads
}

variable "enable_deletion_protection" {
  description = "Enable deletion protection on RDS — should be true in production"
  type        = bool
  default     = true
  # Default true — better protected than not
}

variable "backup_retention_days" {
  description = "Number of days RDS backups are retained"
  type        = number
  default     = 7
  # 7 days is a common standard
}

Avoiding Over-Parameterization #

Too many variables make a module hard to use. Every variable added is cognitive load for the caller.

# ANTI-PATTERN: Over-parameterized — too many detail variables
variable "subnet_cidr_bits"              { type = number }
variable "subnet_newbits"                { type = number }
variable "subnet_netnum_offset"          { type = number }
variable "nat_gateway_eip_allocation_id" { type = string }
variable "route_table_propagation_vgws"  { type = list(string) }
variable "dhcp_options_domain_name"      { type = string }
variable "dhcp_options_domain_name_servers" { type = list(string) }
# 50+ variables for things that actually have good defaults

# CORRECT: Opinionated defaults, expose only what's truly needed
variable "cidr_block" {
  type = string
  # One variable for the CIDR — the module calculates its own subnets
}

variable "az_count" {
  type    = number
  default = 2
  # The module can calculate subnet CIDRs internally
}

# If there's a rare override need, use an optional object:
variable "advanced_config" {
  description = "Advanced configuration — only use if the defaults don't fit"
  type = object({
    enable_nat_per_az          = optional(bool, false)
    dhcp_domain_name_servers   = optional(list(string), ["AmazonProvidedDNS"])
  })
  default = {}
}

Pass-Through Variable Patterns #

For modules wrapping complex resources, avoid duplicating every resource argument as a module variable. Use a more selective pattern.

# ANTI-PATTERN: Duplicating all RDS arguments as module variables
variable "db_engine"                    { type = string }
variable "db_engine_version"            { type = string }
variable "db_instance_class"            { type = string }
variable "db_allocated_storage"         { type = number }
variable "db_max_allocated_storage"     { type = number }
variable "db_storage_type"              { type = string }
variable "db_storage_encrypted"         { type = bool }
variable "db_kms_key_id"               { type = string }
variable "db_username"                  { type = string }
variable "db_port"                      { type = number }
# ... 30 more variables

# CORRECT: Opinionated defaults + an escape hatch for advanced overrides
variable "instance_class" {
  type    = string
  default = "db.t3.medium"
}

variable "allocated_storage" {
  type    = number
  default = 100
}

# Escape hatch: an object for uncommon overrides
variable "db_overrides" {
  description = "RDS configuration overrides not covered by the main variables. See the aws_db_instance documentation for available fields."
  type        = any
  default     = {}
}

resource "aws_db_instance" "this" {
  instance_class    = var.instance_class
  allocated_storage = var.allocated_storage

  # Merge overrides into the base configuration
  # (only if using dynamic blocks or a provider that supports it)
}

Tags as an Interface Pattern #

Tags are a good example of a flexible interface — let callers define their own tags and the module adds the tags it needs.

# Recommended pattern for tags
variable "tags" {
  description = "Map of tags applied to all resources created by this module"
  type        = map(string)
  default     = {}
}

# Inside the module, merge caller tags with the module's required tags
locals {
  common_tags = merge(
    var.tags,
    {
      # Tags that always exist, can't be overridden by callers
      ManagedBy = "terraform"
      Module    = "vpc"
    }
  )
}

resource "aws_vpc" "this" {
  cidr_block = var.cidr_block
  tags       = merge(local.common_tags, { Name = "${var.name}-vpc" })
}

resource "aws_subnet" "public" {
  count = var.az_count
  # ...
  tags = merge(local.common_tags, {
    Name = "${var.name}-public-${count.index + 1}"
    Tier = "public"
  })
}

Complete Output Design #

Good outputs ensure callers don’t need to access the module’s internal resources directly.

# outputs.tf — comprehensive output design

# Output IDs for all main resources
output "vpc_id" {
  value       = aws_vpc.this.id
  description = "ID of the created VPC"
}

# Output the ARN if the resource has one
output "vpc_arn" {
  value       = aws_vpc.this.arn
  description = "VPC ARN"
}

# List outputs for resources created multiple times
output "public_subnet_ids" {
  value       = aws_subnet.public[*].id
  description = "List of public subnet IDs, ordered by AZ"
}

# Object outputs for easy multi-attribute access
output "nat_gateways" {
  description = "NAT Gateway information per AZ"
  value = {
    for idx, eip in aws_eip.nat : idx => {
      id        = aws_nat_gateway.this[idx].id
      public_ip = eip.public_ip
    }
  }
}

# ANTI-PATTERN: Outputs exposing internal implementation details
# that callers don't need
output "route_table_association_ids" {
  value = aws_route_table_association.public[*].id
  # Callers almost never need this — don't expose it if not necessary
}


Interface Documentation #

# GOOD: Documented interface contract
# modules/networking/README.md
#
# ## Input Interface
# | Variable | Type | Required | Default | Description |
# |----------|------|----------|---------|-------------|
# | vpc_cidr | string | yes | - | VPC CIDR block |
# | azs | list(string) | yes | - | Availability zones |
# | environment | string | yes | - | Environment name |
#
# ## Output Interface  
# | Output | Type | Description |
# |--------|------|-------------|
# | vpc_id | string | VPC ID |
# | subnet_ids | list(string) | Private subnet IDs |
# | nat_gateway_ips | list(string) | NAT gateway public IPs |

# Interface consistency pattern
# All networking modules must output:
# - vpc_id
# - subnet_ids (private)
# - subnet_ids (public)

# All compute modules must input:
# - subnet_ids
# - security_group_ids
# - instance_type
# Generate interface documentation
terraform-docs markdown table ./modules/networking

# Validate interface compliance
# scripts/validate-interface.sh
#!/bin/bash
MODULE=$1
REQUIRED_OUTPUTS="vpc_id subnet_ids"
for output in $REQUIRED_OUTPUTS; do
  if ! grep -q "output \"$output\"" "$MODULE/outputs.tf"; then
    echo "MISSING: $output in $MODULE"
    exit 1
  fi
done

Interface Versioning #

# When a module interface changes, bump the MAJOR version
# v1.0.0: variable "name" = string
# v2.0.0: variable "name" = object({first = string, last = string})

# Backward compatibility pattern:
# Add a new optional variable (MINOR bump)
# DON'T remove or rename variables (MAJOR bump)

# v1.1.0: add an optional variable
variable "tags" {
  description = "Resource tags"
  type        = map(string)
  default     = {}
}
# Interface validation
# Make sure all consumer modules are compatible
terraform plan -target=module.consumer 2>&1 | grep "incompatible"
# If there's an error, update the consumer module version

# Bulk version update
find . -name "*.tf" -exec sed -i 's|module-version/1.0|module-version/2.0|g' {} \;

Summary #

  • Few required variables — only expose what truly can’t be defaulted. Every required variable is a hurdle for callers.
  • Opinionated defaults are better than no defaults — a module with sensible defaults is immediately usable with minimal configuration.
  • Avoid over-parameterization — use optional objects (advanced_config) as an escape hatch for rarely needed configuration.
  • Tags via merge(var.tags, {}) — let callers add their own tags, the module adds required tags with merge.
  • Comprehensive outputs — make sure all attributes callers might need are available as outputs, but don’t expose irrelevant internal implementation details.
  • Easy to use correctly, hard to use incorrectly — use validation, sensible defaults, and clear names to encourage proper usage.

← Previous: Versioning   Next: Registry →

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