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.
By the end of this mission
- Use
for_eachon 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
forexpressions
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.
Part 2
Your project after this mission · 3 files change
- 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
Declare the service catalogue
Everything that differs per service goes in the map; everything shared goes in defaults.
load_balanceris 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
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
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
Expose all services as one output
A
forexpression 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 withfor_eachcreates both services. - ✓The migration from named module calls applied with zero changes.
- ✓
terraform output service_namesreturns 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.
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).
Part 5
Interview questions from this mission
How do you create multiple instances of a module and reference their outputs?
What changes need moved blocks during a module refactor?
What are the trade-offs of defining infrastructure as a map of services with one for_each module call?