Terraform

Terraform Module Design Best Practices: Reusability and Maintainability

2026-04-19
NicheeLab Editorial Team

ハブ記事: Terraform Modules: Complete Guide

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

Modules need to deliver reusability while drawing boundaries that hold up under change. This article focuses on stable design guidelines aligned with the official docs, organized so they double as exam prep.

In particular, minimizing inputs and outputs, handling providers correctly, pinning versions with SemVer, and choosing between count and for_each have the biggest impact on reproducibility and maintainability.

1. Design Principles: Single Responsibility and Clear Boundaries

Keep each module focused on a single responsibility and keep its inputs (variables) and outputs minimal. A small API surface with few side effects makes it easier to swap implementations and to absorb future change.

As a rule, configure providers in the root module and only declare required_providers in child modules. This preserves consumers' freedom to choose authentication, region, and so on.

Make external coupling explicit. Keep things like external resource IDs and tagging strategy at the input boundary, and avoid resolving names inside the module (i.e. don't overuse data sources).

  • Restrict inputs/outputs to the minimum needed (avoid breaking changes)
  • Configure providers in the root; assume inheritance in children (pass aliases explicitly when needed)
  • Hide internal implementation: abstract resource names and structure behind outputs
  • Exam angle: memorize the roles of root vs. child modules and the default behavior of provider inheritance

Module boundaries and provider inheritance, at a glance

InheritPass aliasRoot Moduleterraform { required_providers } / provider "aws"module "network"source = ./modules/networkInherits the default aws providermodule "compute"providers = { aws = aws.us-west-2 }The root module configures the provider and inherits / explicitly passes aliases to child modules

Skeleton of a minimal module

# modules/network/
# main.tf
resource "aws_vpc" "this" {
  cidr_block = var.cidr_block
}

# variables.tf
variable "cidr_block" {
  type = string
}

# outputs.tf
output "vpc_id" {
  value = aws_vpc.this.id
}

# ルート側使用例(providersはルートで設定)
module "network" {
  source     = "./modules/network"
  cidr_block = "10.0.0.0/16"
}

2. Input Variable Design: Types, Safety, and Validation

Give inputs strict types and allow only meaningful defaults. Vague any types reduce reusability and increase the risk of future breaking changes.

Use validation to catch business-rule violations early, and sensitive = true to keep secrets from leaking. Receive shared things like tags as map(string) and normalize them in locals.

Because list order tends to drift, prefer designs that use map/set with stable keys for data that drives instance branching.

  • Use concrete types (string, number, bool, list(string), map(string), object({...}), etc.)
  • Check format and range early using validation
  • Mark secrets as sensitive and never emit them in plaintext
  • Prefer map-based designs intended for for_each to avoid order dependence

Concrete variables.tf examples (types and validation)

variable "name" {
  type = string
  validation {
    condition     = length(var.name) > 0 && length(var.name) <= 63
    error_message = "nameは1〜63文字で指定してください。"
  }
}

variable "tags" {
  type        = map(string)
  description = "共通タグ"
  default     = {}
}

variable "ports" {
  type = set(number)
  validation {
    condition     = alltrue([for p in var.ports : p > 0 && p < 65536])
    error_message = "portsは1〜65535の範囲で指定してください。"
  }
}

variable "db_password" {
  type      = string
  sensitive = true
  description = "データベース接続パスワード(外部から安全に注入)"
}

3. Outputs and Dependencies: Minimizing Coupling

Limit outputs to the identifiers and connection info consumers actually need; don't leak internal resource structure. Prefer stable values like ARNs and IDs so your module survives future implementation changes.

Mark sensitive outputs to protect confidential data, and use depends_on between modules only when you need explicit ordering. When references exist, the implicit dependency they create takes precedence; depends_on is a last resort.

On the exam, you need to clearly articulate the role of outputs and the meaning of depends_on on a module call.

  • Keep outputs minimal (IDs, ARNs, endpoints, etc.)
  • Use sensitive = true for confidential values
  • Only use depends_on on module calls when there is no implicit dependency

Examples of outputs and module-level depends_on

# modules/network/outputs.tf
output "vpc_id" {
  value = aws_vpc.this.id
}

output "private_subnet_ids" {
  value = aws_subnet.private[*].id
}

# ルートモジュール側:明示的な順序が必要なケース
module "network" {
  source     = "./modules/network"
  cidr_block = "10.0.0.0/16"
}

module "app" {
  source = "./modules/app"
  vpc_id = module.network.vpc_id
  # vpc_id参照があるため通常は暗黙依存で十分
}

# 参照が存在しないが順序制御したい特殊ケース
module "audit" {
  source = "./modules/audit"
  depends_on = [module.network]
}

4. Providers and Versioning: Ensuring Predictability

Set required_version in the terraform block to force the whole team onto the same Terraform version range. In required_providers, spell out each provider's source and version constraint.

Child modules should not configure providers; they should only declare requirements via required_providers, letting the root module control behavior via inheritance or explicit aliases.

Version the module itself with VCS tags and follow SemVer: breaking changes bump the major version, backward-compatible features bump the minor, and bug fixes bump the patch.

  • Pin a minimum CLI version with required_version
  • Pin each provider's source and version range in required_providers
  • Release modules with VCS tags following SemVer
  • Exam angle: be able to explain the ~> operator and the defaults of provider inheritance

terraform, provider, and how to pass providers to a module

# ルートモジュール
terraform {
  required_version = ">= 1.3.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"  # 5.xに固定(将来の6.xを除外)
    }
  }
}

provider "aws" {
  region = "us-east-1"
}

provider "aws" {
  alias  = "us_west_2"
  region = "us-west-2"
}

module "compute" {
  source    = "./modules/compute"
  providers = {
    aws = aws.us_west_2
  }
}

# 子モジュール側(modules/compute)
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 5.0"
    }
  }
}
# provider "aws" ブロックは原則ここでは定義しない(ルートから継承)

5. Structure, Documentation, and Quality Checks

Split directories under modules/ by capability, and put minimal usage examples in examples/. The README should clearly list inputs, outputs, dependencies, and supported version ranges.

In CI, use terraform fmt -recursive and terraform validate to enforce style and syntax consistency, and watch plan diffs. Publish modules via tags and call out breaking changes in the CHANGELOG.

Examples are the fastest path to real-world testing. Keep examples/ as a minimal config that actually runs plan/apply, and make sure readers can override the provider setup.

  • Maintain modules/, examples/, and a README (inputs/outputs/compatibility) as a set
  • Basic CI trio: fmt, validate, plan
  • Use version tags and a changelog to keep downstream users safe

Recommended layout and quality checks

# リポジトリ構成例
.
├── modules/
│   ├── network/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   └── outputs.tf
│   └── compute/
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
├── examples/
│   └── simple/
│       ├── main.tf
│       └── providers.tf
└── README.md

# CIの基本(シェル例)
terraform fmt -recursive
terraform validate
terraform -chdir=examples/simple init
terraform -chdir=examples/simple plan

6. Patterns and Anti-Patterns That Drive Reusability

When you're adding/removing instances or key stability matters, prefer for_each over count to avoid index drift. Repeated nested blocks should be generated via dynamic blocks.

Over-relying on data sources for external lookups increases apply-order and environment dependencies, which hurts maintainability. As a rule, take required IDs and config via inputs and normalize them in locals.

Common anti-patterns: accepting any for everything, leaking internal implementation through outputs, and locking in providers by configuring them in child modules. They strip downstream consumers of flexibility and invite breaking changes.

  • Add/remove resources → for_each; repeated nested blocks → dynamic; simple fixed counts → count is fine
  • Minimize external lookups; lock the boundary at inputs and normalize in locals
  • Avoid configuring providers in child modules (use required_providers only)
Aspectcountfor_eachdynamic block
Main use caseSimple replication of identical resources (count-based)Keyed instances (map/set-based)Dynamic generation of nested blocks
Addressingres.name[count.index]res.name["key"]Only inside the parent resource's config (no address)
Change stabilityOrder changes tend to trigger recreationStable keys → minimal diffsDoes not affect the resource address
Deletion behaviorTends to remove from the highest index downDestroys only the matching keyRemoves only the matching block
Input datanumberset(string)/map(T) etc.Any collection via a for_each expression

Address stability: for_each vs. count

# count(順序ゆらぎに弱い)
variable "names" { type = list(string) }
resource "aws_iam_user" "u" {
  count = length(var.names)
  name  = var.names[count.index]
}
# namesの順序を変えると多数の置換が発生しうる

# for_each(キーが安定すれば強い)
variable "users" { type = set(string) }
resource "aws_iam_user" "u2" {
  for_each = var.users
  name     = each.value
}
# キー(各ユーザー名)が同一であれば差分は最小化される

Check Your Understanding

Pro

問題 1

You are designing a VPC module to be reused across multiple accounts and regions. Which approach best maximizes reusability and maintainability?

  1. Declare only required_providers in the child module and configure providers in the root. Use strict input types with validation. Manage the module via VCS tags following SemVer, treating breaking changes as major version bumps.
  2. Pin the provider inside the child module and hardcode the region. Accept inputs as map(any) with no validation. Have consumers reference the module's latest branch.
  3. Implement every add/remove of resources with count and manage addresses based on order. Set no provider version constraints.
  4. Expose no outputs at all and let consumers directly reference internal resource names.

正解: A

A matches the official recommendation. Configure providers in the root and have child modules only declare requirements via required_providers. Strict input types with validation stabilize the boundary, and VCS tags + SemVer make releases predictable. The other options undermine reusability and predictability.

Frequently Asked Questions

Is it OK to define a provider block inside a child module?

Generally avoid it. Child modules should only declare dependencies via required_providers, while actual provider configuration (auth, region, endpoint, etc.) belongs in the root module and is inherited. When you need multiple configurations, use aliases and pass them explicitly via the providers argument on the module call.

When should you use depends_on on a module call?

Only in the special case where you need to control creation order but there is no implicit dependency via references. Normally, referring to another module's outputs as an input establishes the implicit dependency and depends_on is unnecessary.

How should I design collections to minimize breaking changes?

Prefer for_each with map/set so that the key identifying each resource instance is stable. Relying on count + list tends to trigger massive replacements when ordering shifts. Also expose only stable IDs/ARNs in outputs and avoid leaking internal structure.

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.