module-authoring-guide
sidebar_position: 5 title: "Module Authoring Guide" description: "Custom Terraform module patterns, DO/DON'T conventions, and testing strategy for the terraform-aws library."
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.
| Factor | Use Wrapper | Use Custom |
|---|---|---|
| Outputs needed? | No (pass-through) | Yes — custom module required |
| Composition required? (combining multiple resources) | No | Yes — custom module required |
| ABAC/JIT/SCIM features needed? | No | Yes — custom module required |
| Lines of code budget | Under 50 (wrapper overhead) | Under 500 (lean custom core) |
| Registry publish? | Low bar for wrapper | Full 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
awsccprovider - 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
| Pattern | Example |
|---|---|
| Direct resources (no wrapper) | resource "aws_ssoadmin_permission_set" "pset" {} — direct AWS resource definitions |
| Typed variables | type = map(object({ ... })) with validation blocks; not type = any |
| Real outputs | value = { for k, v in aws_resource.x : k => v.arn } — output actual resource attributes |
| YAML + HCL dual input | yamldecode(file(...)) merged with HCL variable overrides for audit trails |
| Mock provider tests (Tier 1) | mock_provider "aws" {} + override_data {} for structure validation |
| Flattened for_each | flatten() → 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-Pattern | Why It's Wrong | Recommended Fix |
|---|---|---|
| Wrapper-only module | module "x" { source = "..." } with zero outputs and no composition | Use direct resources instead; wrappers add overhead for no value |
| Empty outputs | output "x" { value = {} } | Output real resource references: value = aws_resource.x.arn |
| Untyped variables | type = any for all inputs | Use typed objects: type = map(object({ ... })) with validation |
| Backend block in module | Terraform anti-pattern; breaks module reusability | Configure backend only in examples/ and projects/ (compositions), never in modules/ |
| Override module in tests | override_module bypasses all validation | Use override_data for data sources instead; validates module logic properly |
| Count for for_each resources | count is index-based and fragile under resource removal | Use for_each with map keys for stable addressing |
| Hardcoded ARNs | ARNs are region/account-specific; breaks portability | Use data sources or input variables instead |
Resource Naming Conventions
Consistent naming improves readability and debuggability across the module library.
| Convention | Example | When to Use |
|---|---|---|
| Resource type prefix | aws_ssoadmin_permission_set.pset | Always; the resource type defines the namespace |
| Plural for for_each | aws_identitystore_group.sso_groups | When iterating: for_each loops use plural names |
| Singular for count | aws_s3_bucket.audit_archive | When 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.
| Tier | Tool | Cost | Coverage | File Location |
|---|---|---|---|---|
| Tier 1 | .tftest.hcl + mock_provider | $0 | Plan validation, output structure, schema compliance | tests/snapshot/*.tftest.hcl |
| Tier 2 | LocalStack + Go Terratest | $0 | Resource creation, state, integration without real AWS | tests/localstack/ (external) |
| Tier 3 | Real AWS + Go Terratest | $$ | Full integration, real resource lifecycle, account-specific edge cases | tests/integration/ (gated, HITL approval) |
Tier 1: Mock Provider Tests (Mandatory)
Tier 1 tests validate that the module:
- Plans without errors — all variables pass validation
- Produces expected outputs — outputs contain the correct resource references
- 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
.tffiles 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 APIretained for audit-friendly inputs (CPS 234 compliance) - At least 6 Tier 1 test cases in
tests/snapshot/*.tftest.hclusingmock_provider -
README.mdauto-generated byterraform-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"
}
Related Resources
- Code Organization Patterns — repository layout, example naming (ADR-005), state management (ADR-006)
- ADR-007 — Upstream Dependency Strategy — when and how to wrap upstream modules
- ADR-005 — Example Naming — mvp-, poc-, production- tier conventions
- ADR-004 — 3-Tier Testing — complete testing strategy
- ADR-003 — Provider Constraints — version pinning rationale
- Terraform Documentation — official reference