LNLYDRD BLOG
Cover Image for Stop Repeating Yourself: The Terraform Mapping Construct Pattern

Stop Repeating Yourself: The Terraform Mapping Construct Pattern

Breck
Breck

If you've ever copy-pasted a Terraform resource block and changed just a few values, you've felt the pain. Managing dozens of similar AWS resources—Lambda functions, S3 buckets, security groups—quickly becomes a maintenance nightmare.

What actually works is a simple, repeatable pattern you can drop into any Terraform project. The Terraform Mapping Construct Pattern reduces infrastructure code by up to 90% while enforcing consistency and making refactoring trivial. Here's your five minute write up to hopefully unlock some efficiencies in your workflow.

The Problem: The Copy-Paste Nightmare

Here's what managing multiple Lambda functions typically looks like:

resource "aws_lambda_function" "data_processor" {
  function_name = "app-data-processor"
  runtime       = "python3.12"
  memory_size   = 512
  timeout       = 60
  handler       = "lambda_function.lambda_handler"
  role          = aws_iam_role.lambda_role.arn
  
  vpc_config {
    subnet_ids         = var.subnet_ids
    security_group_ids = [aws_security_group.lambda_sg.id]
  }
  
  environment {
    variables = {
      LOG_LEVEL = "INFO"
    }
  }
  
  tags = {
    Environment = "production"
  }
}

resource "aws_lambda_function" "api_handler" {
  function_name = "app-api-handler"
  runtime       = "python3.12"
  memory_size   = 512
  timeout       = 60
  handler       = "lambda_function.lambda_handler"
  role          = aws_iam_role.lambda_role.arn
  
  vpc_config {
    subnet_ids         = var.subnet_ids
    security_group_ids = [aws_security_group.lambda_sg.id]
  }
  
  environment {
    variables = {
      LOG_LEVEL = "INFO"
    }
  }
  
  tags = {
    Environment = "production"
  }
}

# ... repeat for 10 more functions

Notice the duplication? Most of these properties are identical. Now imagine:

  • Changing the runtime version across all functions
  • Updating VPC configuration
  • Adding a new tag to every resource
  • Scaling to 50+ Lambda functions

This is where the mapping construct pattern shines.

The Solution: Three-Layer Architecture

The pattern separates concerns into three files. This isn't just documentation—it's a practical approach that works in production environments.

  1. variables.tf - Define defaults once
  2. locals.tf - Specify only what's unique
  3. main.tf - Deploy everything with for_each

Let's rebuild those Lambda functions using this pattern.

Layer 1: Define Defaults (variables.tf)

First, establish your organizational standards:

variable "app_name" {
  description = "Application name prefix"
  type        = string
  default     = "my-app"
}

variable "default_lambda_config" {
  description = "Default Lambda function configuration"
  type        = any
  default = {
    runtime                = "python3.12"
    memory_size            = 512
    timeout                = 60
    handler                = "lambda_function.lambda_handler"
    log_retention_in_days  = 7
    tags = {
      Environment = "production"
      ManagedBy   = "Terraform"
    }
  }
}

These defaults apply to every Lambda function unless explicitly overridden. Change the runtime here, and all functions inherit it.

Layer 2: Specify Uniqueness (locals.tf)

Now define only what makes each function different:

locals {
  # Custom configurations - specify ONLY unique properties
  custom_lambda_config = {
    "data-processor" = {
      description = "Processes incoming data files"
      timeout     = 300  # Override default 60s
      environment_variables = {
        BUCKET_NAME = "my-app-data"
      }
    }
    "api-handler" = {
      description = "Handles API requests"
      memory_size = 1024  # Override default 512MB
      environment_variables = {
        API_KEY = "secret-key"
      }
    }
    "report-generator" = {
      description = "Generates daily reports"
      timeout     = 600
      memory_size = 2048
    }
  }
  
  # MERGE: Combine defaults + custom + computed values
  lambda_config = {
    for name, config in local.custom_lambda_config : name => merge(
      var.default_lambda_config,  # Start with defaults
      config,                      # Override with custom values
      {
        # Add computed properties
        function_name = "${var.app_name}-${name}"
        source_path   = "${path.module}/src/lambda/${name}"
      }
    )
  }
}

The magic happens in the merge() function. It combines three layers:

  1. Start with defaults from var.default_lambda_config
  2. Override with resource-specific values from config
  3. Add computed properties like function_name

The result is each function in lambda_config is fully configured, but you only wrote the unique parts. This isn't just theory—it's what actually works in production environments.

Layer 3: Deploy Everything (main.tf)

Finally, deploy all functions with a single module block:

module "lambda_function" {
  source  = "terraform-aws-modules/lambda/aws"
  version = "7.14.0"
  
  for_each = local.lambda_config
  
  function_name         = each.value.function_name
  description           = each.value.description
  handler               = each.value.handler
  runtime               = each.value.runtime
  memory_size           = each.value.memory_size
  timeout               = each.value.timeout
  source_path           = each.value.source_path
  environment_variables = each.value.environment_variables
  tags                  = each.value.tags
}

That's it. One module block deploys all three functions (or 30 functions—it doesn't matter).

The Power of for_each

The for_each meta-argument iterates over your configuration map. Each key becomes a resource instance. Want to add a fourth function? Just add it to custom_lambda_config:

locals {
  custom_lambda_config = {
    "data-processor" = { ... }
    "api-handler" = { ... }
    "report-generator" = { ... }
    "email-sender" = {  # New function!
      description = "Sends notification emails"
      timeout     = 120
    }
  }
}

No changes to main.tf needed. The pattern scales linearly.

Advanced: Conditional Deployment

Sometimes you need different deployment strategies. Use inline filters with for_each:

# Deploy standard Lambda functions
module "lambda_function" {
  source   = "terraform-aws-modules/lambda/aws"
  for_each = {
    for name, config in local.lambda_config : name => config
    if try(config.docker_deploy, false) == false
  }
  
  function_name = each.value.function_name
  handler       = each.value.handler
  source_path   = each.value.source_path
}

# Deploy Docker-based Lambda functions
module "lambda_function_docker" {
  source   = "terraform-aws-modules/lambda/aws"
  for_each = {
    for name, config in local.lambda_config : name => config
    if try(config.docker_deploy, false) == true
  }
  
  function_name  = each.value.function_name
  package_type   = "Image"
  image_uri      = each.value.image_uri
  create_package = false
}

Same configuration map, different deployment methods. The filter determines which functions go where.

Cross-Resource Relationships

The pattern handles resource dependencies elegantly. Consider Lambda functions that need security groups:

# In main.tf - Security groups use the same map
resource "aws_security_group" "lambda_sg" {
  for_each = local.lambda_config  # Same map!
  
  name        = "${each.value.function_name}-sg"
  description = "Security group for ${each.value.function_name}"
  vpc_id      = var.vpc_id
  
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# Lambda functions reference their security groups
module "lambda_function" {
  source   = "terraform-aws-modules/lambda/aws"
  for_each = local.lambda_config
  
  function_name          = each.value.function_name
  vpc_security_group_ids = [aws_security_group.lambda_sg[each.key].id]
  # ... other properties
}

Add a function to the map, get its security group automatically. Perfect synchronization.

Real-World Example: S3 Buckets

The pattern works for any AWS resource. Here's S3 buckets:

# variables.tf
variable "default_s3_config" {
  default = {
    versioning_enabled = true
    force_destroy      = false
    tags = {
      Environment = "production"
    }
  }
}

# locals.tf
locals {
  custom_s3_config = {
    "data-lake" = {
      description = "Main data lake bucket"
    }
    "temp-files" = {
      description   = "Temporary processing files"
      force_destroy = true  # Override default
      lifecycle_rules = [{
        enabled = true
        expiration_days = 7
      }]
    }
    "backups" = {
      description = "Application backups"
      versioning_enabled = true
    }
  }
  
  s3_config = {
    for name, config in local.custom_s3_config : name => merge(
      var.default_s3_config,
      config,
      { bucket_name = "${var.app_name}-${name}" }
    )
  }
}

# main.tf
module "s3_bucket" {
  source  = "terraform-aws-modules/s3-bucket/aws"
  version = "4.2.1"
  
  for_each = local.s3_config
  
  bucket        = each.value.bucket_name
  force_destroy = each.value.force_destroy
  versioning = {
    enabled = each.value.versioning_enabled
  }
  tags = each.value.tags
}

Three buckets, minimal code, perfect consistency.

Handling Resource References

When resources reference each other, timing matters:

For Resources Not Yet Created

Use string interpolation with predictable naming:

locals {
  custom_lambda_config = {
    "data-processor" = {
      environment_variables = {
        # Bucket doesn't exist yet, but we know its name
        BUCKET_NAME = "${var.app_name}-data-lake"
      }
      policy_statements = {
        s3_access = {
          effect  = "Allow"
          actions = ["s3:GetObject", "s3:PutObject"]
          # Construct ARN from known naming pattern
          resources = [
            "arn:aws:s3:::${var.app_name}-data-lake/*"
          ]
        }
      }
    }
  }
}

For Already-Created Resources

Reference module outputs directly:

locals {
  custom_lambda_config = {
    "data-processor" = {
      environment_variables = {
        # Reference actual created resource
        TABLE_ARN = aws_dynamodb_table.main.arn
      }
    }
  }
}

Terraform's dependency graph handles the rest.

Pattern Benefits

  1. DRY Principle: Define defaults once, inherit everywhere
  2. Consistency: Impossible to have configuration drift
  3. Easy Refactoring: Change defaults in one place
  4. Scalability: Linear growth (O(n) resources = O(n) config lines)
  5. Map Reusability: One map drives multiple resource types
  6. Maintainability: Clear separation of concerns

When to Use This Pattern

Perfect for:

  • Multiple Lambda functions
  • Multiple S3 buckets
  • Multiple security groups
  • Multiple IAM roles
  • Any resource type with 3+ instances

Not needed for:

  • Single-instance resources
  • Resources with completely unique configurations
  • Simple proof-of-concept projects

Getting Started

  1. Identify repetition in your Terraform code
  2. Extract common properties into default_*_config variables
  3. Create custom config maps with only unique properties
  4. Merge configurations using for loops and merge()
  5. Deploy with for_each in your module/resource blocks

Complete Working Example

Terraform Mapping Construct Example

src
lambda
locals.tf
main.tf
outputs.tf
terraform.tfvars.example
variables.tf

locals.tf

locals {
  # ============================================================================
  # LAMBDA FUNCTIONS - Custom Configurations
  # ============================================================================
  # Specify ONLY properties that differ from defaults in variables.tf
  
  custom_lambda_config = {
    "data-processor" = {
      description = "Processes incoming data files from S3"
      timeout     = 300  # Override default 60s for longer processing
      memory_size = 1024 # Override default 512MB for data processing
      environment_variables = {
        SOURCE_BUCKET = "${var.app_name}-uploads"
        DEST_BUCKET   = "${var.app_name}-processed"
        LOG_LEVEL     = "INFO"
      }
      # IAM policy for S3 access
      attach_policy_statements = true
      policy_statements = {
        s3_read = { sid = "S3Read", effect = "Allow", actions = ["s3:GetObject", "s3:ListBucket"], resources = ["arn:aws:s3:::${var.app_name}-uploads", "arn:aws:s3:::${var.app_name}-uploads/*"] }
        s3_write = { sid = "S3Write", effect = "Allow", actions = ["s3:PutObject"], resources = ["arn:aws:s3:::${var.app_name}-processed/*"] }
      }
    }
    
    "api-handler" = {
      description = "Handles API requests and returns responses"
      memory_size = 256  # Override - API handler needs less memory
      environment_variables = {
        API_VERSION = "v1"
        LOG_LEVEL   = "DEBUG"
      }
      # IAM policy for CloudWatch Logs only (inherited from defaults)
    }
  }
  
  # ============================================================================
  # MERGE OPERATION - DO NOT DELETE
  # ============================================================================
  # Combines defaults + custom configs + computed properties
  
  lambda_config = {
    for name, config in local.custom_lambda_config : name => merge(
      var.default_lambda_config,  # Layer 1: Baseline defaults
      config,                      # Layer 2: Resource-specific overrides
      {
        # Layer 3: Computed transformations
        function_name = "${var.app_name}-${name}"
        source_path   = "${path.module}/src/lambda/functions/${name}"
        layers        = try(config.layers, [])  # Add layers if specified
      }
    )
  }
  
  # ============================================================================
  # S3 BUCKETS - Custom Configurations
  # ============================================================================
  
  custom_s3_config = {
    "uploads" = {
      description = "User file uploads bucket"
      cors_rule = [{
        allowed_headers = ["*"]
        allowed_methods = ["PUT", "POST", "GET"]
        allowed_origins = ["*"]
        expose_headers  = ["ETag"]
        max_age_seconds = 3600
      }]
    }
    
    "processed" = {
      description = "Processed data storage"
      lifecycle_rule = [{
        id      = "archive-old-data"
        enabled = true
        transition = [{
          days          = 90
          storage_class = "GLACIER"
        }]
      }]
    }
    
    "temp-files" = {
      description   = "Temporary processing files"
      force_destroy = true
      lifecycle_rule = [{
        id      = "cleanup-temp-files"
        enabled = true
        expiration = {
          days = 7
        }
      }]
    }
  }
  
  # ============================================================================
  # MERGE OPERATION - DO NOT DELETE
  # ============================================================================
  
  s3_config = {
    for name, config in local.custom_s3_config : name => merge(
      var.default_s3_config,
      config,
      {
        bucket = "${var.app_name}-${name}"
      }
    )
  }
  
  # ============================================================================
  # LAMBDA LAYERS - Custom Configurations
  # ============================================================================
  
  custom_lambda_layer_config = {
    "shared-utils" = {
      description         = "Shared utility functions for Lambda"
      layer_folder_name   = "shared-utils"
    }
  }
  
  # ============================================================================
  # MERGE OPERATION - DO NOT DELETE
  # ============================================================================
  
  lambda_layer_config = {
    for name, config in local.custom_lambda_layer_config : name => merge(
      var.default_lambda_layer_config,
      config,
      {
        layer_name  = "${var.app_name}-${name}"
        source_path = "${path.module}/src/lambda/layers/${config.layer_folder_name}"
      }
    )
  }
}

Conclusion

The Terraform Mapping Construct Pattern transforms infrastructure-as-code from repetitive copy-paste work into elegant, maintainable configuration. By separating defaults, customization, and deployment, you'll write less code, enforce consistency, and make refactoring trivial.

Start small: pick one resource type with multiple instances and apply the pattern. You'll immediately see the benefits. Then expand to your entire infrastructure.

Your future self (and your team) will thank you.


Bonus: AI-Assisted Terraform

Want AI assistants like GitHub Copilot or Kiro to automatically generate mapping constructs? Add this steering file to your project:

Create .kiro/steering/terraform-format.md:

<!-- ---
inclusion: fileMatch
fileMatchPattern: ['**/*.tf', '**/*.hcl']
---
-->

# Terraform Standards

## Multi-Resource Pattern

**CRITICAL**: For multiple instances of the same resource type, always use the mapping construct pattern:

### 1. Define defaults in variables.tf
variable "default_lambda_config" {
  default = {
    runtime     = "python3.12"
    memory_size = 512
    timeout     = 60
  }
}

### 2. Merge configurations in locals.tf
locals {
  custom_lambda_config = {
    "function-name" = {
      memory_size = 1024  # Override
    }
  }
  
  lambda_config = {
    for name, config in local.custom_lambda_config : name => merge(
      var.default_lambda_config,
      config,
      { function_name = "${var.app_name}-${name}" }
    )
  }
}

### 3. Deploy with for_each in main.tf
module "lambda_function" {
  source   = "terraform-aws-modules/lambda/aws"
  for_each = local.lambda_config
  
  function_name = each.value.function_name
  runtime       = each.value.runtime
  memory_size   = each.value.memory_size
}

Now AI tools will automatically follow this pattern when generating Terraform code for your project.