Command Palette

Search for a command to run...

Hectal

Mission 5.4 · Stage 5 — Modules

Services as Data: for_each over Modules

Goal: All ShopLite services are declared in one map; a single module block with for_each creates them, and moving from two named calls to the map is a zero-change plan.

35 min Free 4 steps 2 break-it drills

By the end of this mission

  • Use for_each on a module call and read module outputs as a map
  • Build a service catalogue as a local map with shared defaults
  • Migrate module.api → module.service["api"] with moved blocks
  • Recognise duplicate-key and unknown-key errors in for expressions

Part 1

Understand it first

Configuration as data

Once two things are built the same way, the differences between them are just DATA. A services map (api → its settings, worker → its settings) makes the whole fleet visible in one place, and adding a third service is one new map entry. module "service" { for_each = local.services ... } creates one module instance per key, addressed module.service["api"].

Shared defaults go in one place too: merge(local.service_defaults, each.value) lets each entry override only what differs.

Module outputs become maps

With for_each, module.service is a map of objects keyed like the input. module.service["api"].service_name reads one; { for k, m in module.service : k => m.service_name } builds an output map of all of them.

When NOT to go this far

Two services with very different shapes (one public with a load balancer, one internal) can be clearer as two explicit module calls. The map approach shines when services are mostly similar and numerous. It's a trade-off between DRY and readable; choose based on how many services you really expect.

One block, many instancesdiagram
Rendering diagram…

Part 2

Your project after this mission · 3 files change

shoplite/
  • app/
    • worker.js
  • infra/
    • alb.tf
    • backend.tf
    • checks.tf
    • database.tf
    • ecr.tf
    • ecs.tfmodified
    • iam.tf
    • locals.tf
    • logs.tf
    • main.tf
    • outputs.tfmodified
    • probe.tf
    • providers.tf
    • refactors.tfmodified
    • security.tf
    • storage.tf
    • terraform.tfvars
    • uploads.tf
    • variables.tf
    • versions.tf
  • modules/
    • ecs-service/
      • main.tf
      • outputs.tf
      • variables.tf
      • versions.tf
    • network/
      • main.tf
      • outputs.tf
      • variables.tf
      • versions.tf

Part 3

Build it, step by step

  1. 1

    Declare the service catalogue

    Everything that differs per service goes in the map; everything shared goes in defaults. load_balancer is only present for the API.

    infra/ecs.tfadd to filehcl

    Replaces the separate module "api" and module "worker" blocks.

    locals {
      service_defaults = {
        command       = null
        cpu           = 256
        memory        = 512
        load_balancer = null
        autoscaling   = { min = 1, max = 1 }
        environment   = merge(local.db_env, { APP_VERSION = var.image_tag })
      }
    
      services = {
        api = merge(local.service_defaults, {
          log_group = aws_cloudwatch_log_group.api.name
          environment = merge(local.db_env, {
            PORT           = tostring(var.app_port)
            APP_VERSION    = var.image_tag
            UPLOADS_BUCKET = aws_s3_bucket.uploads.bucket
          })
          load_balancer = {
            target_group_arn = aws_lb_target_group.api.arn
            container_port   = var.app_port
            resource_label   = "${aws_lb.main.arn_suffix}/${aws_lb_target_group.api.arn_suffix}"
          }
          autoscaling = { min = 2, max = 6, requests_per_target = 500 }
        })
    
        worker = merge(local.service_defaults, {
          log_group = aws_cloudwatch_log_group.worker.name
          command   = ["node", "worker.js"]
        })
      }
    }
    
    module "service" {
      source   = "git::https://github.com/you/shoplite.git//modules/ecs-service?ref=ecs-service-v1.0.0"
      for_each = local.services
    
      name               = "${local.name_prefix}-${each.key}"
      container_name     = each.key
      cluster_id         = aws_ecs_cluster.main.id
      image              = local.image
      command            = each.value.command
      cpu                = each.value.cpu
      memory             = each.value.memory
      execution_role_arn = aws_iam_role.ecs_execution.arn
      task_role_arn      = aws_iam_role.api_task.arn
      subnet_ids         = module.network.private_subnet_ids
      security_group_ids = [aws_security_group.app.id]
      log_group_name     = each.value.log_group
      environment        = each.value.environment
      secrets            = local.db_secrets
      load_balancer      = each.value.load_balancer
      autoscaling        = each.value.autoscaling
    
      depends_on = [aws_lb_listener.http]
    }
  2. 2

    Move the named module instances

    A moved block can move an entire module instance: every resource inside moves with it.

    infra/refactors.tfadd to filehcl
    moved {
      from = module.api
      to   = module.service["api"]
    }
    
    moved {
      from = module.worker
      to   = module.service["worker"]
    }
  3. 3

    Plan: moves only

    Every resource in both services appears as moved, and the summary is zeros. Apply.

    terminal
    $ terraform init && terraform apply
    ── expected output ──
    # module.api.aws_ecs_service.this has moved to module.service["api"].aws_ecs_service.this
    ...
    # module.worker.aws_ecs_service.this has moved to module.service["worker"].aws_ecs_service.this
    ...
    Plan: 0 to add, 0 to change, 0 to destroy.
  4. 4

    Expose all services as one output

    A for expression over the module map. CI in Stage 7 uses this to know which services to wait on after a deploy.

    infra/outputs.tfadd to filehcl
    output "service_names" {
      value = { for k, m in module.service : k => m.service_name }
    }
    terminal
    $ terraform apply -auto-approve >/dev/null && terraform output service_names
    ── expected output ──
    {
    "api" = "shoplite-dev-api"
    "worker" = "shoplite-dev-worker"
    }

Checkpoint — you should now have

  • ✓One module "service" block with for_each creates both services.
  • ✓The migration from named module calls applied with zero changes.
  • ✓terraform output service_names returns a map of both services.
  • ✓Adding a service would be one new entry in local.services.

Part 4

Break it on purpose

Make each change, run the command, and read the error before revealing the diagnosis. Recognising these messages on sight is what makes you fast on a real team. Undo the change afterwards.

Break #1

Build a map with duplicate keys

Imagine services came from a list: add service_list = [{ name = "api" }, { name = "worker" }, { name = "api" }] and service_map = { for s in local.service_list : s.name => s } to locals, then run terraform validate.

terminal
$ terraform validate
── what you'll see ──
╷
│ Error: Duplicate object key
│
│ on ecs.tf line 5, in locals:
│ 5: service_map = { for s in local.service_list : s.name => s }
│ ├────────────────
│ │ s.name is "api"
│
│ Two different items produced the key "api" in this 'for' expression. If
│ duplicates are expected, use the ellipsis (...) after the value expression
│ to enable grouping by key.
╵

Break #2

Forget the module-level moved blocks

Delete the two moved { from = module.api ... } / module.worker blocks and plan (in an environment that hasn't applied them yet).

terminal
$ terraform plan
── what you'll see ──
# module.api.aws_ecs_service.this will be destroyed
# (because module.api is not in configuration)
...
# module.service["api"].aws_ecs_service.this will be created
...
Plan: 9 to add, 0 to change, 9 to destroy.

Part 5

Interview questions from this mission

01

How do you create multiple instances of a module and reference their outputs?

02

What changes need moved blocks during a module refactor?

03

What are the trade-offs of defining infrastructure as a map of services with one for_each module call?

0/4 · 0%