Terraform

Terraform Module Design Patterns: Composite, Layered, and Single-Purpose

2026-04-19
NicheeLab Editorial Team

ハブ記事: Terraform Modules: Complete Guide

Hub article covering the full picture of Terraform modules: design, distribution, and operations

With Terraform, the same end state can have very different operational cost and blast radius depending on how you slice up the design. This article gives concrete guidance on when to reach for single-purpose modules, composite modules, and the layered model.

The discussion is grounded in stable, general behavior from HashiCorp's official documentation. Since specific features vary across clouds and versions, pattern selection here prioritizes maintainability and a clear apply order.

The Big Picture of Module Design Patterns

A single-purpose module focuses on one clear responsibility (e.g., VPC, subnet, security group) and keeps inputs and outputs minimal. A composite module bundles several single-purpose modules together, embedding guardrails and standard settings to boost reusability. Layers use the root module (stack) as a boundary to enforce apply order and separation of concerns (e.g., Bootstrap, Network, Platform, Application).

In practice, slicing too finely into single-purpose modules increases the burden on consumers, while leaning too hard on a giant composite or a single layer widens the blast radius when things go wrong. The right balance depends on your organization's change frequency and operating model.

  • Single-purpose: keep them small, stabilize I/O. Diffs are obvious and they're easy to test.
  • Composite: embed organizational guardrails (tags, encryption, naming conventions).
  • Layered: enforce apply order and responsibility boundaries, making parallel operation per environment (dev/stg/prod) easier.
AspectSingle-purpose moduleComposite moduleLayer (root module)
PurposeMinimize responsibility to maximize reuseCombine single-purpose modules with embedded standardsClarify apply order and boundaries
Scope of responsibilityA resource type or small groupingPer workload or per domainInfrastructure tiers (Network/Platform/App, etc.)
Input/output granularityFew inputs, strict types, stable outputsBundled object inputs, representative outputsOnly the minimal outputs needed by other layers
Dependency handlingExplicitly delegated to the parentOrder among child modules is coordinated internallyLoosely coupled to other layers via outputs
When to applyComponentization in isolation, testability-firstRapid rollout of a standard configurationEnvironment separation, responsibility split, parallel deployments
Blast radius on failureSmall (easy to roll back)Medium (scoped to the bundle)Large within the layer, but unlikely to spill beyond the boundary

Single-purpose Modules: Small, Clear Interfaces

Single-purpose modules use strict input types, plus defaults and validations, to guard against consumer mistakes. Outputs are pared down to the minimum that downstream callers actually need, which reduces the surface area for breaking changes.

Keep variables as scalars or small objects — avoid catch-all map variables that accept anything. When outputs contain sensitive data, mark them sensitive so they don't get inadvertently exposed in plan output at higher levels.

  • Make type declarations mandatory on variables, and constrain ranges/patterns with validation.
  • Limit outputs to only the keys downstream callers actually need.
  • Centralize naming conventions and tagging via defaults (locals) where possible, rather than taking them as inputs.
  • Batch breaking changes into major versions and provide compatibility-alias outputs to ease migration.

Composite Modules: Wiring Children and Embedding Guardrails

A composite module locks in the wiring diagram between single-purpose modules and bakes in organizational standards like tagging, encryption, and logging. Consumers only need to supply a small set of inputs to get a standards-compliant stack.

When passing aliased providers to child modules, you must propagate them explicitly via the providers map. If you use for_each to spawn multiple children, use stable keys (e.g., logical names) to keep diffs quiet.

  • Centralize standard tags and encryption defaults in locals, then pass them explicitly to children.
  • Use immutable logical names as for_each keys to suppress diff noise.
  • Pin child module versions and roll them forward in stages.
  • When using provider aliases, propagate them explicitly via providers.

Skeleton of a composite module (network_stack)

variable "name" { type = string }
variable "region" { type = string }
variable "network" {
  type = object({
    cidr_block = string,
    subnets = map(object({ az = string, cidr_block = string }))
  })
}
variable "security_rules" { type = list(object({
  description = string,
  protocol    = string,
  from_port   = number,
  to_port     = number,
  cidr_blocks = list(string)
})) }

locals {
  default_tags = { "managed-by" = "terraform", "stack" = var.name }
}

provider "aws" { region = var.region }

module "vpc" {
  source     = "../modules/vpc"
  name       = var.name
  cidr_block = var.network.cidr_block
  tags       = local.default_tags
}

module "subnet" {
  for_each   = var.network.subnets
  source     = "../modules/subnet"
  vpc_id     = module.vpc.id
  az         = each.value.az
  cidr_block = each.value.cidr_block
  tags       = local.default_tags
}

module "sg" {
  source   = "../modules/security_group"
  vpc_id   = module.vpc.id
  name     = "${var.name}-base"
  rules    = var.security_rules
  tags     = local.default_tags
}

output "vpc_id" { value = module.vpc.id }
output "subnet_ids" { value = [for s in module.subnet : s.id] }
output "security_group_id" { value = module.sg.id }

Layers: Separating Apply Order and Environment Boundaries

Layers split at the root-module level to make the apply order explicit. A typical setup is 4 layers: Bootstrap (backend, IAM least privilege), Network (VPC, etc.), Platform (EKS/ECS/Compute foundation), and Application (services). Each layer owns its own state, and inter-layer coordination flows through outputs.

Keep inter-layer dependencies loosely coupled via outputs. When you really do need a value from another layer, read it with data.terraform_remote_state — but watch out for circular dependencies and over-referencing. Run environments (dev/stg/prod) in parallel via directory separation and separate backends, with CI running plan/apply layer-by-layer in order.

  • Each layer owns an independent backend (state) and lock.
  • Limit inter-layer communication to a minimal set of outputs and avoid circular references.
  • Separate environments by directory/backend, and use workspaces as a supplement when needed.
  • Enforce the order Network → Platform → Application in the pipeline.
Layer 0: Bootstrap(Backend, IAM minima)Layer 1: Network(VPC, Subnet, SG, NAT)Layer 2: Platform(EKS/ECS/ASG, Observability)Layer 3: Application(services, ALB, DNS)Example layered structure (environments expand in parallel). Each layer has independent state; dev/stg/prod run the same configuration side by side.

Versioning and Compatibility: Conventions That Keep Things from Breaking

Modules should follow semantic versioning, grouping breaking changes (renamed inputs, removed outputs, logical changes that force resource replacement) into major version bumps. Always ship a changelog and migration steps alongside.

On the consumer side, pin Terraform itself and providers via required_version and required_providers, and constrain registry-distributed modules via version. When a child module requires specific provider configuration (e.g., aliases), document how to pass it through explicitly via the providers map.

  • Breaking changes are major; backward-compatible additions are minor; bug fixes are patch.
  • When adding inputs, provide defaults so existing consumers aren't affected.
  • When renaming outputs, keep both for a while with a deprecation window.
  • Validate consistency between required_providers and provider version ranges in CI.

Common Exam Targets and Anti-patterns

The Pro exam frequently tests separation of concerns, dependency handling, provider propagation, choosing between for_each and count, and handling sensitive data. Answers that favor state separation at module boundaries and minimal outputs tend to be correct.

Common anti-patterns include catch-all map variables, monolithic modules applied in one shot, excessive cross-layer remote_state references, for_each on volatile keys, and failing to propagate provider aliases.

  • Use for_each with stable keys (logical names). count can be fragile when ordering changes.
  • depends_on works on modules, but don't overuse it — prefer dependencies expressed through outputs.
  • Keep sensitive outputs to the bare minimum and avoid exposing them in higher-level plans.
  • When passing provider aliases to children, do it explicitly via the providers argument on the module block.
  • Don't rely on workspaces alone for environment separation — split state and permission boundaries too.

Check Your Understanding

Pro

問題 1

You need to enforce organizational standards for tagging, encryption, and network design across multiple environments (dev/stg/prod), while absorbing per-app differences through a minimal set of inputs. You also want to keep the blast radius small on failure and make the apply order explicit. Which design is most appropriate?

  1. Provide composites built from single-purpose modules for each layer (Bootstrap/Network/Platform/Application), and separate state per environment at the layer level
  2. Consolidate all environments and all layers into a single giant root module and create everything in a single apply
  3. Componentize every resource into single-purpose modules called directly from the root, passing tags and encryption settings as inputs each time
  4. Don't use modules at all — reference existing resources via data sources and add resources ad hoc

正解: A

Composites with embedded standards reduce input burden and drift, and layering makes apply order and state boundaries explicit — matching the requirements. A giant monolith widens the blast radius, wiring single-purpose modules directly is high-input and prone to drift, and data sources alone can neither create nor enforce standards.

Frequently Asked Questions

Should I separate environments by directory or by workspace?

For production use, the default should be directory separation with separate backends (state), and workspaces should be used as a supplement when needed. This makes it easier to keep permission boundaries, locks, and lifecycles independent per environment.

Should I pass values between layers via variables or terraform_remote_state?

Pass values directly via variables and outputs between modules within the same layer, and only use terraform_remote_state to reference a minimal set of outputs when crossing layer boundaries. Avoid circular dependencies and excessive references — keep the boundaries loosely coupled.

How should modules handle provider versions and aliases?

Declare provider version ranges explicitly in required_providers at the root, and define aliases (e.g., for separate regions) as needed. Pass aliases to child modules explicitly via the providers argument on the module block. Document the expected provider configuration in the module's README so compatibility is preserved.

Check what you learned with practice questions

Practice with certification-focused question sets

無料で問題を解いてみる
Author

NicheeLab Editorial Team

NicheeLab editorial team focused on data engineering and cloud certification learning. Content is structured around practical study needs and official exam domains.


Related articles
Terraform

HCL Syntax: Terraform's Configuration Language (2026)

HCL2 fundamentals for Terraform — blocks, attributes, expres...

Terraform

Terraform Authoring & Operations Pro: Complete Guide (2026)

Tactics for the Terraform Pro exam — module authoring, works...

Terraform

Terraform Providers: Plugin Management Fundamentals (2026)

Provider mechanics — required_providers, versions, mirrors, ...

Terraform

Terraform Resource Blocks: Declarative Infra Units (2026)

Resource block fundamentals — addresses, references, common ...

Terraform

Terraform Data Sources: Read-Only External Data (2026)

Data source basics — declaration, refresh behavior, dependen...

Browse all Terraform articles (102)
© 2026 NicheeLab All rights reserved.