← All articles
Published Sep 22, 20255 min read

Terraform Complex Data Structures Are a Smell—Use Modules Instead

Why giant JSON inputs plus `for_each` loops hurt teams, and how to replace them with composable Terraform modules.

Gabriel Levasseur

Gabriel Levasseur

Founder

Terraform Complex Data Structures Are a Smell—Use Modules Instead

A common Terraform anti-pattern looks like this: craft a massive map of objects or JSON blob, feed it into a module, then use for_each, lookup, and jsonencode gymnastics to create dozens of resources. It feels powerful—one input, many outputs—but it strips away Terraform's type hints, hides drift, and turns simple changes into data-shape archaeology.

This post explains why the "complex input + dynamic resource" combo hurts teams and how to replace it with Terraform modules that act as reliable building blocks.

The JSON factory anti-pattern

variable "listeners" {
  type = any
}

resource "aws_lb_listener" "this" {
  for_each = { for l in var.listeners : l.name => l }

  load_balancer_arn = var.alb_arn
  port              = each.value.port
  protocol          = each.value.protocol
  default_action {
    type             = each.value.action.type
    target_group_arn = each.value.action.target_group
  }
}

locals {
  rules = jsondecode(templatefile("rules.json.tpl", { config = var.listeners }))
}

The module author expects callers to pass a nested JSON structure with hidden defaults. Consumers have no idea which fields exist without spelunking into templates. Terraform can't warn when keys are missing or typos slip in because the variable is typed as any or a loose map(any).

Teams sometimes try to "fix" this by declaring rich types, then rebuilding the structure in locals:

variable "listeners" {
  type = map(object({
    port     = number
    protocol = string
    action   = object({
      type          = string
      target_group  = string
      stickiness_on = optional(bool, false)
    })
  }))
}

locals {
  https_listeners = merge(
    {
      for name, listener in var.listeners :
      name => merge(listener, {
        certificate_arn = coalesce(listener.certificate_arn, var.default_cert_arn)
        security_policy = lookup(listener.policies, var.cluster_name, "ELBSecurityPolicy-TLS13-1-2-2021-06")
      })
    }
  )
}

Now the variable declaration looks typed, but the real interface lives inside locals composed from merge, lookup, coalesce, and for expressions. Consumers still have to reverse-engineer the data contract—and maintainers still debug sprawling expressions when requirements change.

Why it hurts

  • No type safety: Terraform only validates data after the for_each runs, often during plan. Typos become runtime errors instead of compiler feedback.
  • Invisible drift: Wrapped jsonencode and templatefile calls hide the resulting infrastructure. Review diffs show meaningless blobs instead of concrete resource changes.
  • Un-testable logic: Unit tests must recreate the entire JSON expectation, making failure output unreadable.
  • Harder onboarding: New engineers need to reverse-engineer the data contract before making a change.
Quick smell test

If you need a README table just to explain the keys inside a single variable, you're probably encoding behavior that belongs in modules.

Treat modules as the abstraction layer

Terraform modules are the abstraction. Instead of one mega-input, expose smaller modules that mirror real-world capabilities. For our load balancer example:

  • alb-listener module owns a single listener, validates protocol/port combinations, and exposes outputs for rules.
  • alb-rule module manages a rule with clear arguments for path, host, and target group.
  • A higher-level module stitches the above together by instantiating the right count of listeners and rules.

Callers now compose modules declaratively:

module "checkout_listener" {
  source      = "../modules/alb-listener"
  alb_arn     = module.checkout_alb.arn
  port        = 443
  protocol    = "HTTPS"
  target_arn  = module.checkout_service.target_arn
}

module "checkout_rule" {
  source          = "../modules/alb-rule"
  listener_arn    = module.checkout_listener.arn
  host_header     = "checkout.example.com"
  priority        = 10
  target_group_arn = module.checkout_service.target_arn
}

Terraform can now validate types upfront, autocomplete works, and reviewers see explicit resource intent.

Prefer looping over modules (or even copying a handful of module blocks with different inputs) instead of building mega-objects. A simple for_each that instantiates module "alb_listener" three times is easier to understand than a thicket of merge calls funneling towards a single resource.

How to unwind complex inputs

  1. Map fields to capabilities: List every key in the JSON blob and group them by the resource they influence.
  2. Create focused modules: Build modules for each capability (listener, rule, alarm). Give them typed variables and defaults.
  3. Replace dynamic loops: Instantiate modules directly in the calling stack. When you need N listeners, loop over module "alb_listener" with for_each or duplicate module blocks with clear naming. Use for_each on resources only when the resource itself is the repeated primitive.
  4. Promote outputs: Surface IDs and ARNs so sibling modules can compose without shared JSON structures.

Guardrails going forward

  • variable blocks should declare explicit object types; avoid any unless you're truly dealing with opaque data. If you catch yourself rebuilding the object with merge and lookup in locals, split the module instead.
  • Ban jsondecode/templatefile inside business logic modules. If you must decode, do it once at the edge and expose typed outputs.
  • Document module usage with examples instead of schema diagrams for giant variables. Show the handful of module instantiations teams should copy when they need another listener, rule, or pipeline.

Shifting from complex data inputs to module composition restores Terraform's strengths: predictable plans, first-class type checking, and readable reviews. Your team gains confidence that infrastructure changes behave as intended, and tools like Cora can display clear relationships without deciphering hand-rolled JSON factories.

Visualize your Terraform relationships

Ready to see Cora in action? Jump into the product experience tailored to this article.

Visualize your Terraform relationships

Keep reading

View all