Skip to main content

module-authoring-guide


Module Authoring Guide

This guide provides canonical patterns and decision criteria for authoring custom Terraform modules in the terraform-aws library. It complements the Code Organization Patterns guide with actionable DO/DON'T conventions, testing tiers, and a decision matrix for choosing between wrapper and custom module approaches.

Terraform: >= 1.11.0 | AWS Provider: >= 6.28, < 7.0 | License: Apache-2.0


Decision Matrix: When to Wrap vs Build Custom

Before creating a new custom module, evaluate whether to wrap an existing upstream module or build from scratch.

FactorUse WrapperUse Custom
Outputs needed?No (pass-through)Yes — custom module required
Composition required? (combining multiple resources)NoYes — custom module required
ABAC/JIT/SCIM features needed?NoYes — custom module required
Lines of code budgetUnder 50 (wrapper overhead)Under 500 (lean custom core)
Registry publish?Low bar for wrapperFull credibility for custom

When to wrap (Option B pattern)

Wrapping is appropriate when:

  • An upstream module (e.g., aws-ia/terraform-aws-sso) covers 80%+ of your use case
  • You need to add enterprise defaults (tag governance, YAML config API, APRA CPS 234 compliance checks)
  • The upstream provider version aligns with your constraints

See ADR-007 — Upstream Dependency Strategy for the canonical wrapper pattern and attribution requirements.

When to build custom (Option C pattern)

Build custom when:

  • No suitable upstream module exists for the domain
  • Upstream uses incompatible provider versions or awscc provider
  • Upstream is unstable, unmaintained, or introduces unnecessary complexity
  • You need specific composition (combining multiple AWS resources into a cohesive unit)

Custom Module DO/DON'T Patterns (Option C)

DO: Correct Patterns

PatternExample
Direct resources (no wrapper)resource "aws_ssoadmin_permission_set" "pset" {} — direct AWS resource definitions
Typed variablestype = map(object({ ... })) with validation blocks; not type = any
Real outputsvalue = { for k, v in aws_resource.x : k => v.arn } — output actual resource attributes
YAML + HCL dual inputyamldecode(file(...)) merged with HCL variable overrides for audit trails
Mock provider tests (Tier 1)mock_provider "aws" {} + override_data {} for structure validation
Flattened for_eachflatten() → composite keys for stable resource addressing
Copyright headers# Copyright 2026 platform@oceansoft.io. Licensed under Apache-2.0. See LICENSE.

DON'T: Anti-Patterns

Anti-PatternWhy It's WrongRecommended Fix
Wrapper-only modulemodule "x" { source = "..." } with zero outputs and no compositionUse direct resources instead; wrappers add overhead for no value
Empty outputsoutput "x" { value = {} }Output real resource references: value = aws_resource.x.arn
Untyped variablestype = any for all inputsUse typed objects: type = map(object({ ... })) with validation
Backend block in moduleTerraform anti-pattern; breaks module reusabilityConfigure backend only in examples/ and projects/ (compositions), never in modules/
Override module in testsoverride_module bypasses all validationUse override_data for data sources instead; validates module logic properly
Count for for_each resourcescount is index-based and fragile under resource removalUse for_each with map keys for stable addressing
Hardcoded ARNsARNs are region/account-specific; breaks portabilityUse data sources or input variables instead

Resource Naming Conventions

Consistent naming improves readability and debuggability across the module library.

ConventionExampleWhen to Use
Resource type prefixaws_ssoadmin_permission_set.psetAlways; the resource type defines the namespace
Plural for for_eachaws_identitystore_group.sso_groupsWhen iterating: for_each loops use plural names
Singular for countaws_s3_bucket.audit_archiveWhen count is used (rare; for_each preferred)
Composite key"${pset_name}.${policy_arn}"for_each key combining multiple identifiers for unique addressing

Example: Composite key for for_each

resource "aws_ssoadmin_permission_set_inline_policy" "policy_assignments" {
for_each = {
for k, p in var.permission_sets : "${k}.inline" => p
if try(p.inline_policy, null) != null
}

instance_arn = aws_ssoadmin_instances.main[0].arn
permission_set_arn = aws_ssoadmin_permission_set.psets[split(".", each.key)[0]].arn
inline_policy = each.value.inline_policy
}

Testing Strategy (3-Tier Approach)

Every custom module must include tests at all three tiers. Tests are grouped by cost and coverage.

TierToolCostCoverageFile Location
Tier 1.tftest.hcl + mock_provider$0Plan validation, output structure, schema compliancetests/snapshot/*.tftest.hcl
Tier 2LocalStack + Go Terratest$0Resource creation, state, integration without real AWStests/localstack/ (external)
Tier 3Real AWS + Go Terratest$$Full integration, real resource lifecycle, account-specific edge casestests/integration/ (gated, HITL approval)

Tier 1: Mock Provider Tests (Mandatory)

Tier 1 tests validate that the module:

  1. Plans without errors — all variables pass validation
  2. Produces expected outputs — outputs contain the correct resource references
  3. Maintains schema integrity — resource attributes match provider expectations

Minimum requirement: 6 test cases per module.

Tier 1 test file example

# tests/snapshot/01_mandatory.tftest.hcl
run "sso_groups_creation" {
command = plan

variables {
sso_groups = {
Admin = {
group_name = "Admin"
group_description = "Admin group"
}
Dev = {
group_name = "Dev"
group_description = "Developer group"
}
}
}

# Assert the outputs are populated
assert {
condition = length(output.sso_groups_ids) == 2
error_message = "Expected 2 groups in output."
}

assert {
condition = can(output.sso_groups_ids["Admin"])
error_message = "Expected Admin group in output."
}
}

run "yaml_validation" {
command = plan

variables {
sso_config_yaml = file("${path.module}/../fixtures/valid-config.yaml")
}

assert {
condition = can(yamldecode(var.sso_config_yaml))
error_message = "YAML config must be valid."
}
}

Tier 2: LocalStack Integration Tests

Tier 2 tests validate resource creation and state changes WITHOUT incurring AWS costs. These are typically written in Go (Terratest) and run against LocalStack.

Not required for every module — prioritize by complexity. Modules with composition (multiple AWS resource types) benefit most from Tier 2 coverage.

Tier 3: Real AWS Integration Tests (HITL-Gated)

Tier 3 tests run against real AWS accounts and validate full lifecycle (create, update, destroy). These are:

  • Expensive ($)
  • Account-specific (may fail in different regions/accounts)
  • Gated by HITL approval in CI/CD

Tier 3 is a pre-release validation, not a continuous integration gate.


Quick Validation Actions

After scaffolding or modifying a custom module, run these validation commands to confirm compliance:

# 1. Format and validate syntax
terraform -chdir=modules/<module-name> fmt -check -recursive
terraform -chdir=modules/<module-name> init -backend=false
terraform -chdir=modules/<module-name> validate

# 2. Run Tier 1 tests (plan-only, mock provider)
terraform -chdir=tests/snapshot init -backend=false
terraform -chdir=tests/snapshot test -verbose

# 3. Check LOC budget (should be under 500 for core module files)
wc -l modules/<module-name>/*.tf

# 4. Verify outputs are not empty
terraform -chdir=tests/snapshot test -verbose 2>&1 | grep -c "pass"
# Expected: >= 6 test cases passing

# 5. Verify copyright headers (Apache-2.0 compliance)
grep -l "Copyright 2026" modules/<module-name>/*.tf
# Expected: all .tf files show up

Module Scaffold Template

When creating a new custom module, start with this file structure. All files are mandatory.

modules/<module-name>/
├── main.tf # Direct AWS resources (no wrapper)
├── variables.tf # Typed variables with validation
├── outputs.tf # Real outputs (ARNs, IDs, maps)
├── locals.tf # Data transformations, tag defaults
├── data.tf # Read-only data sources
├── versions.tf # Provider constraints (>= 6.28, < 7.0; terraform >= 1.11.0)
├── CHANGELOG.md # Semver changelog
├── VERSION # Plain semver (e.g., "0.1.0")
├── README.md # terraform-docs auto-generated
├── NOTICE.txt # Attribution if derived from upstream (Apache-2.0)
├── LICENSE # Apache-2.0 full text
├── configs/ # YAML config files (for audit trail / CPS 234)
│ └── example-config.yaml
├── examples/ # Runnable examples (ADR-005 naming: mvp-, poc-, production-)
│ ├── mvp-basic/
│ ├── poc-advanced-feature/
│ └── production-enterprise/
└── tests/ # .tftest.hcl files (Tier 1 mandatory)
├── 01_mandatory.tftest.hcl
├── 02_composition.tftest.hcl
└── snapshot/
└── yaml_validation_test.tftest.hcl

Scaffold Checklist

  • All .tf files have Apache-2.0 copyright header: # Copyright 2026 platform@oceansoft.io. Licensed under Apache-2.0. See LICENSE.
  • versions.tf: required_version = ">= 1.11.0", aws provider >= 6.28, < 7.0
  • NO backend block in any module file (backend config in compositions only)
  • outputs.tf: All outputs reference real resource attributes (no empty maps)
  • variables.tf: All variables use typed objects; validation blocks where applicable
  • YAML config API retained for audit-friendly inputs (CPS 234 compliance)
  • At least 6 Tier 1 test cases in tests/snapshot/*.tftest.hcl using mock_provider
  • README.md auto-generated by terraform-docs (or equivalent)
  • LOC budget: module core files (main.tf, variables.tf, outputs.tf, locals.tf) under 500 lines total

Examples: Module Naming and Structure

Example 1: Simple wrapper (Option B)

# modules/sso/main.tf
# Copyright 2026 platform@oceansoft.io. Licensed under Apache-2.0.
# Derived from aws-ia/terraform-aws-sso v1.0.4 (Apache-2.0). See NOTICE.

module "aws_sso" {
source = "aws-ia/terraform-aws-sso/aws"
version = "~> 1.0.4"

sso_users = var.sso_users
sso_groups = var.sso_groups
tags = local.merged_tags
}

# Wrapper outputs: pass-through from upstream
output "sso_group_ids" {
value = module.aws_sso.sso_group_ids
description = "Upstream output: SSO group IDs"
}

Example 2: Custom module with composition (Option C)

# modules/sso/main.tf
# Copyright 2026 platform@oceansoft.io. Licensed under Apache-2.0.

data "aws_ssoadmin_instances" "main" {}

resource "aws_ssoadmin_permission_set" "psets" {
for_each = var.permission_sets

instance_arn = data.aws_ssoadmin_instances.main.arns[0]
name = each.key
description = each.value.description
session_duration = each.value.session_duration
tags = merge(local.default_tags, each.value.tags)
}

resource "aws_ssoadmin_permission_set_inline_policy" "policies" {
for_each = {
for k, p in var.permission_sets : k => p
if can(p.inline_policy)
}

instance_arn = data.aws_ssoadmin_instances.main.arns[0]
permission_set_arn = aws_ssoadmin_permission_set.psets[each.key].arn
inline_policy = each.value.inline_policy
}

output "permission_set_arns" {
value = {
for k, v in aws_ssoadmin_permission_set.psets : k => v.arn
}
description = "Map of permission set ARNs"
}