code-organization-patterns
sidebar_position: 4 title: "Code Organization Patterns" description: "Canonical patterns for structuring, wrapping, naming, and managing state in the terraform-aws module library."
Code Organization Patterns
This guide documents the canonical code organization patterns used in the terraform-aws module library (oceansoft/terraform-aws/aws). Each pattern is grounded in an Architecture Decision Record (ADR) and is demonstrated with real code from the repository.
Terraform: >= 1.11.0 | AWS Provider: >= 6.28, < 7.0 | Region: ap-southeast-2 (primary), us-east-1 (Identity Center)
Pattern 1: Repository Layout
The repository follows a domain-driven directory structure that separates reusable modules from configuration, governance, and tooling concerns.
terraform-aws/
├── modules/ # Publishable Terraform modules (Registry-ready)
│ ├── iam-identity-center/ # Domain: identity & access
│ │ ├── main.tf # Resource definitions
│ │ ├── variables.tf # Input variables with validation
│ │ ├── outputs.tf # Module outputs
│ │ ├── locals.tf # Internal computed values
│ │ ├── data.tf # Data source lookups
│ │ ├── versions.tf # Provider + Terraform constraints (ADR-003)
│ │ ├── examples/ # Runnable examples (ADR-005 naming)
│ │ │ ├── create-users-and-groups/
│ │ │ ├── production-multi-account-landing-zone/
│ │ │ └── google-workspace/
│ │ └── tests/ # .tftest.hcl files (ADR-004 3-tier)
│ │ ├── 01_mandatory.tftest.hcl
│ │ ├── snapshot/
│ │ └── ...
│ ├── ecs-platform/ # Domain: container workloads
│ └── web/ # Domain: web application hosting
│
├── global/ # Shared variable conventions (not importable)
│ └── global_variables.tf # Documents tag taxonomy + common var shapes
│
├── projects/ # Live compositions using modules
│ └── iam-identity-center/ # Active deployment (source override: ADR-012)
│
├── docs/
│ └── adr/ # Architecture Decision Records index
│ └── index.mdx # Canonical ADR table + links
│
├── scripts/ # Automation scripts (govern, monitor, validate)
│ ├── govern-cps234.sh
│ ├── monitor-verify.sh
│ └── validate-sprint.sh
│
├── .claude/ # ADLC framework (agents, commands, skills, hooks)
│ ├── agents/ # 9 specialist agents
│ ├── commands/ # 81 slash commands
│ ├── skills/ # 76 reusable skill definitions
│ └── hooks/scripts/ # 7 governance hooks
│
├── .github/
│ └── workflows/ # Single-responsibility CI workflows
│ ├── ci.yml # validate + lint + test
│ ├── infracost.yml # Cost estimation (separate per ADR pattern)
│ ├── registry-publish.yml # Terraform Registry publish
│ └── docs-sync.yml # DevOps-TechDocs sync
│
├── backend.hcl.example # S3 backend template (ADR-006)
├── Taskfile.yml # All automation entry points
├── docker-compose.yml # DevContainer (nnthanh101/terraform:2.6.0)
└── VERSION # Semver root version file
Key principles:
- Modules under
modules/are self-contained and Registry-publishable. Each has its ownversions.tf,tests/, andexamples/. global/documents shared variable conventions. Because Terraform modules cannot import variables from each other, this file is documentation and a reference for compositions — it is not imported.projects/holds live compositions. During local development, thesourceblock in a project can be overridden to point at the localmodules/path (see ADR-012)..github/workflows/uses the single-responsibility pattern: cost estimation runs ininfracost.yml, not insideci.yml.
Pattern 2: Wrapper Pattern (ADR-007)
The terraform-aws library wraps upstream modules rather than copy-pasting their source code. The canonical upstream is aws-ia/terraform-aws-iam-identity-center. The wrapper adds enterprise controls: tag governance, YAML config API, APRA CPS 234 compliance checks, and provider constraint alignment.
Reference: ADR-007 — Upstream Dependency Strategy
When to wrap vs build custom
| Situation | Approach |
|---|---|
| Upstream module covers 80%+ of the use case | Wrap — add enterprise defaults on top |
Upstream uses incompatible provider version or awscc provider | Fork and rebrand (ADR-007 fork pattern) |
| No suitable upstream exists for the domain | Build custom following module structure pattern (Pattern 5) |
| Upstream is unstable or unmaintained | Build custom; document in an ADR |
Wrapper source block
When consuming the module from the Registry in a composition (projects/):
# projects/iam-identity-center/main.tf
module "aws-iam-identity-center" {
source = "oceansoft/terraform-aws/aws//modules/iam-identity-center"
version = "~> 0.1"
default_tags = local.common_tags
sso_groups = var.sso_groups
sso_users = var.sso_users
permission_sets = var.permission_sets
account_assignments = var.account_assignments
}
When running examples locally (inside modules/iam-identity-center/examples/), the source is a relative path:
# modules/iam-identity-center/examples/create-users-and-groups/main.tf
module "aws-iam-identity-center" {
source = "../.." # local path — resolved to modules/iam-identity-center
default_tags = {
CostCenter = "platform"
Project = "iam-identity-center"
Environment = "example"
ServiceName = "sso"
DataClassification = "internal"
}
sso_groups = {
Admin : {
group_name = "Admin"
group_description = "Admin IAM Identity Center Group"
}
Dev : {
group_name = "Dev"
group_description = "Dev IAM Identity Center Group"
}
}
# ...
}
NOTICE.txt — attribution for derived works
Any module derived from an upstream Apache-2.0 source must include a NOTICE.txt and carry the derivation comment at the top of every .tf file:
# Copyright 2026 [email protected] (oceansoft.io). Licensed under Apache-2.0. See LICENSE.
# Derived from aws-ia/terraform-aws-iam-identity-center v1.0.4 (Apache-2.0). See NOTICE.
Modules built from scratch (not derived) omit the Derived from line.
Pattern 3: Example Naming Convention (ADR-005)
Examples are named using the {tier}-{descriptor} pattern. The tier prefix signals the complexity, blast radius, and intended use case at a glance.
Reference: ADR-005 — Example Naming
Tier prefixes
| Prefix | Intent | AWS Resources | Suitable For |
|---|---|---|---|
mvp- | Minimal viable — fewest variables, zero optional config | < 5 | Quickstart, unit test fixture |
poc- | Proof of concept — demonstrates a specific feature | 5–15 | Feature validation, integration test |
production- | Full enterprise configuration | 15+ | Pre-production review, Tier 3 integration tests |
Directory layout for iam-identity-center examples
modules/iam-identity-center/examples/
├── create-users-and-groups/ # mvp equivalent — core happy path
├── existing-users-and-groups/ # poc — read-only / existing resource pattern
├── google-workspace/ # poc — external IdP integration
├── inline-policy/ # poc — non-managed policy attachment
├── instance-access-control-attributes/ # poc — ABAC configuration
├── create-apps-and-assignments/ # poc — SSO application assignments
├── create-users-and-groups-with-customer-managed-policies/ # poc — CMP attachment
├── existing-users-groups-create-apps/ # poc — mixed existing + new resources
└── production-multi-account-landing-zone/ # production — 4-tier hierarchy, 3 accounts, ABAC
Production example structure
The production-multi-account-landing-zone example demonstrates the full enterprise pattern:
# modules/iam-identity-center/examples/production-multi-account-landing-zone/main.tf
module "aws-iam-identity-center" {
source = "../.."
default_tags = {
CostCenter = "platform"
Project = "landing-zone"
Environment = "production"
ServiceName = "sso"
DataClassification = "confidential"
}
sso_groups = {
LZAdministrators = {
group_name = "LZAdministrators"
group_description = "Landing Zone administrators — full access (break-glass: ADR-020)"
}
LZPowerUsers = {
group_name = "LZPowerUsers"
group_description = "Power users — deploy workloads, no IAM/billing"
}
LZReadOnly = {
group_name = "LZReadOnly"
group_description = "Read-only access for compliance and audit"
}
LZSecurityAudit = {
group_name = "LZSecurityAudit"
group_description = "Security audit — CloudTrail, GuardDuty, Config"
}
}
permission_sets = {
LZAdministratorAccess = {
description = "Full admin access for Landing Zone management (break-glass only, ADR-020)"
session_duration = "PT1H"
aws_managed_policies = ["arn:aws:iam::aws:policy/AdministratorAccess"]
tags = { ManagedBy = "Terraform", Tier = "admin" }
}
LZReadOnlyAccess = {
description = "Read-only access for all accounts"
session_duration = "PT8H"
aws_managed_policies = ["arn:aws:iam::aws:policy/ReadOnlyAccess"]
tags = { ManagedBy = "Terraform", Tier = "read-only" }
}
}
account_assignments = {
LZReadOnly_AllAccounts = {
principal_name = "LZReadOnly"
principal_type = "GROUP"
principal_idp = "INTERNAL"
permission_sets = ["LZReadOnlyAccess"]
account_ids = [
local.management_account_id,
local.security_account_id,
local.workload_account_id,
]
}
}
}
Account IDs come from SSM Parameter Store or terraform_remote_state, not hardcoded values:
# modules/iam-identity-center/examples/production-multi-account-landing-zone/locals.tf
data "aws_ssm_parameter" "account1_account_id" {
name = "tf-aws-iam-idc-module-testing-account1-account-id"
}
locals {
management_account_id = nonsensitive(data.aws_ssm_parameter.account1_account_id.value)
security_account_id = "222222222222" # Replace with SSM or remote state
workload_account_id = "333333333333" # Replace with SSM or remote state
}
Pattern 4: State Management (ADR-006)
The terraform-aws library uses S3 native locking (use_lockfile = true) introduced in Terraform 1.11.0. DynamoDB locking tables are not used.
Reference: ADR-006 — S3 Native State Locking
Backend configuration
The repository ships backend.hcl.example as a template. Copy and customise per account; never commit backend.hcl with real values.
# backend.hcl.example (copy to backend.hcl — gitignored)
bucket = "ams-terraform-org-state"
region = "ap-southeast-2"
use_lockfile = true
# Path pattern: tf-org-aws/<account-id>/<module>/terraform.tfstate
# Example for identity-center in account 123456789012:
# key = "tf-org-aws/123456789012/identity-center/terraform.tfstate"
key = "tf-org-aws/<ACCOUNT_ID>/<MODULE_NAME>/terraform.tfstate"
# State bucket lives in management account
# Ensure cross-account access policy exists on this bucket
encrypt = true
Initialise with the backend config file:
terraform init -backend-config=backend.hcl
State key path convention
tf-org-aws/<12-digit-account-id>/<module-name>/terraform.tfstate
Examples:
tf-org-aws/123456789012/identity-center/terraform.tfstate
tf-org-aws/234567890123/ecs-platform/terraform.tfstate
tf-org-aws/345678901234/web/terraform.tfstate
The path pattern embeds the account ID so multiple accounts can share a single state bucket in the management account without key collisions. Cross-account access is granted via an S3 bucket policy on the management account.
Why not DynamoDB
| Factor | S3 Native Locking (chosen) | DynamoDB Locking (rejected) |
|---|---|---|
| Terraform version | >= 1.11.0 required | All versions |
| Resource overhead | Zero (no extra resource) | DynamoDB table per state bucket |
| Cost | S3 object storage only | DynamoDB read/write capacity |
| Operational surface | Single S3 bucket | S3 bucket + DynamoDB table + IAM for both |
| Lock file format | .tflock object in same bucket | Item in separate table |
Pattern 5: Module Structure
Every module in modules/ follows the same file layout. This predictability is enforced by task build:validate (which runs terraform validate across all modules) and task govern:legal (which checks Apache-2.0 headers on every .tf file).
Standard file layout
modules/<module-name>/
├── main.tf # Resource definitions — the core logic
├── variables.tf # All input variables with type, description, default, validation
├── outputs.tf # All module outputs with description
├── locals.tf # Computed values (transformations, merges, derived config)
├── data.tf # Data source lookups (aws_ssoadmin_instances, aws_organizations_organization)
├── versions.tf # required_version + required_providers (ADR-003)
├── CHANGELOG.md # Semver changelog
├── VERSION # Plain semver string (e.g. "0.1.0")
├── README.md # Module documentation (terraform-docs generated)
├── NOTICE.txt # Attribution for derived works
├── LICENSE # Apache-2.0
├── examples/ # Runnable examples (ADR-005 naming)
└── tests/ # .tftest.hcl test files (ADR-004)
├── 01_mandatory.tftest.hcl
├── 02_existing_users_and_groups.tftest.hcl
└── snapshot/
└── yaml_validation_test.tftest.hcl
versions.tf — the provider constraints anchor
Every module pins its provider constraints in versions.tf. This file is the single source of truth for compatibility (see Pattern 7 for detail):
# modules/iam-identity-center/versions.tf
# Copyright 2026 [email protected] (oceansoft.io). Licensed under Apache-2.0. See LICENSE.
# Provider constraints: ADR-003 (>= 6.28, < 7.0), ADR-007 (no AWSCC)
terraform {
required_version = ">= 1.11.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.28, < 7.0"
}
}
}
locals.tf — computed values and tag defaults
locals.tf handles transformations that would clutter main.tf. A common pattern is merging consumer-supplied tags with module-level defaults:
# modules/iam-identity-center/locals.tf (excerpt)
locals {
# 5 canonical tags: enterprise + FOCUS 1.2+ (via Cost Allocation Tags) + APRA CPS 234
# Defaults satisfy checkov when consumers do not provide default_tags.
_effective_default_tags = merge({
CostCenter = "platform"
Project = "iam-identity-center"
Environment = "unset"
ServiceName = "sso"
DataClassification = "internal"
}, var.default_tags)
}
outputs.tf — typed, described outputs
Every output carries a description. Sensitive outputs are marked sensitive = true:
# modules/iam-identity-center/outputs.tf (excerpt)
output "sso_groups_ids" {
value = { for k, v in aws_identitystore_group.sso_groups : k => v.group_id }
description = "A map of SSO group IDs created by this module"
}
output "sso_users_ids" {
value = { for k, v in aws_identitystore_user.sso_users : k => v.user_id }
description = "A map of SSO user IDs created by this module"
}
output "account_assignment_data" {
value = local.flatten_account_assignment_data
description = "Tuple containing account assignment data"
}
variables.tf — typed with validation
Variables use Terraform's optional() modifier for backwards-compatible extension. Validation blocks catch invalid inputs at plan time rather than apply time:
# modules/iam-identity-center/variables.tf (excerpt)
variable "sso_groups" {
description = "Names of the groups you wish to create in IAM Identity Center."
type = map(object({
group_name = string
group_description = optional(string, null)
}))
default = {}
}
variable "sso_users" {
description = "Names of the users you wish to create in IAM Identity Center."
type = map(object({
display_name = optional(string)
user_name = string
# NOTE: Empty list [] is intentionally valid — represents a standalone user
# without group assignments (e.g. service accounts, direct permission set users).
group_membership = list(string)
given_name = string
middle_name = optional(string, null)
family_name = string
email = string
is_primary_email = optional(bool, true)
# ... additional optional fields
}))
default = {}
}
Pattern 6: Environment Separation
The terraform-aws library uses tfvars files for environment separation rather than Terraform workspaces. This follows the ADR-006 principle that each environment should have an isolated state file path.
Reference: ADR-008 — Tag Governance
Variable precedence (Terraform evaluation order)
1. Environment variables (TF_VAR_<name>) # lowest precedence
2. terraform.tfvars (auto-loaded)
3. *.auto.tfvars (auto-loaded, alphabetical)
4. -var-file=<file>.tfvars (explicit CLI flag)
5. -var <name>=<value> (explicit CLI flag) # highest precedence
tfvars per environment
projects/iam-identity-center/
├── main.tf
├── variables.tf
├── backend.hcl # gitignored — contains real bucket/key
├── dev.tfvars
├── staging.tfvars
└── prod.tfvars
# dev.tfvars
environment = "dev"
cost_center = "platform"
data_classification = "internal"
# staging.tfvars
environment = "staging"
cost_center = "platform"
data_classification = "internal"
# prod.tfvars
environment = "production"
cost_center = "platform"
data_classification = "confidential"
Apply targeting a specific environment:
terraform plan -var-file=prod.tfvars -backend-config=backend.hcl
terraform apply -var-file=prod.tfvars -backend-config=backend.hcl
Global variable conventions
global/global_variables.tf documents the shared variable shapes and the four-tier tag taxonomy. Individual modules define their own variable blocks independently; the global file is a reference, not an import:
# global/global_variables.tf — 4-tier tag taxonomy
variable "environment" {
description = "Deployment environment (dev/staging/prod)"
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Must be: dev, staging, or prod."
}
}
variable "common_tags" {
description = "Tags applied to all resources — 4-tier taxonomy for FOCUS 1.2+ FinOps and APRA CPS 234"
type = map(string)
default = {
# Tier 1 — Mandatory
Project = "terraform-aws"
Environment = "dev"
Owner = "[email protected]"
CostCenter = "platform"
ManagedBy = "Terraform"
# Tier 2 — FinOps (FOCUS 1.2+)
# ServiceName and ServiceCategory set per-module in locals.tf
# Tier 3 — Compliance (APRA CPS 234)
DataClassification = "internal"
Compliance = "none"
# Tier 4 — Operational
Automation = "true"
BackupPolicy = "default"
GitRepo = "terraform-aws"
}
}
Workspace guidance
Terraform workspaces are not used in this library because:
- All workspace state lives in a shared backend — a corrupted workspace can affect all environments.
terraform.workspaceinterpolations make code harder to read and audit.- Separate
tfvarsfiles + separate state key paths provide full isolation with clearer intent.
For regulated workloads (APRA CPS 234), separate backend configurations per environment are strongly preferred.
Pattern 7: Provider Constraints (ADR-003)
Version constraints are set once, in versions.tf, per module. The constraints follow the pessimistic constraint operator (~>) for minor versions and explicit upper bounds for major versions.
Reference: ADR-003 — Provider Constraints
Canonical versions.tf
# modules/<any-module>/versions.tf
# Copyright 2026 [email protected] (oceansoft.io). Licensed under Apache-2.0. See LICENSE.
# Provider constraints: ADR-003 (>= 6.28, < 7.0), ADR-007 (no AWSCC)
terraform {
required_version = ">= 1.11.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 6.28, < 7.0"
}
}
}
Constraint rationale
| Constraint | Value | Rationale |
|---|---|---|
required_version | >= 1.11.0 | S3 native locking (use_lockfile) requires 1.11.0; check blocks and optional() require 1.5+ |
| AWS provider lower bound | >= 6.28 | Minimum version with stable aws_ssoadmin_application and aws_identitystore_* resource coverage |
| AWS provider upper bound | < 7.0 | Prevents silent breakage from major-version API changes; reviewed and bumped deliberately |
awscc provider | Not declared | ADR-007 explicitly excludes awscc — it introduced instability in the upstream source |
Provider configuration in compositions
Modules declare required providers; compositions (examples, projects) configure the provider:
# projects/iam-identity-center/main.tf — provider configuration
provider "aws" {
region = "us-east-1" # IAM Identity Center is a global service, homed in us-east-1
default_tags {
tags = {
ManagedBy = "Terraform"
GitRepo = "terraform-aws"
CostCenter = "platform"
}
}
}
Upgrading providers
The provider-upgrade.yml workflow handles controlled provider upgrades. Manual upgrades should:
- Update the lower bound in
versions.tffor all affected modules. - Run
task build:validateto confirm all modules validate. - Run
task test:tier1to confirm snapshot tests pass. - Update
CHANGELOG.mdwith the semver bump. - Raise a PR for HITL review (ADR-015 root VERSION file gate).
Pattern Quick Reference
| Pattern | ADR | Key File(s) | Gate Command |
|---|---|---|---|
| Repository Layout | — | Taskfile.yml, docker-compose.yml | task ci:quick |
| Wrapper Pattern | ADR-007 | NOTICE.txt, main.tf | task govern:legal |
| Example Naming | ADR-005 | examples/ directory | task build:validate |
| State Management | ADR-006 | backend.hcl.example | terraform init -backend-config=backend.hcl |
| Module Structure | ADR-001 | versions.tf, variables.tf, outputs.tf, locals.tf | task build:validate |
| Environment Separation | ADR-008 | *.tfvars, global_variables.tf | task sprint:validate |
| Provider Constraints | ADR-003 | versions.tf | task build:validate |
See Also
- ADR Index — all 20 ADRs with status and links
- 3-Tier Testing Guide — snapshot, LocalStack, integration test tiers (ADR-004)
- Security Best Practices — APRA CPS 234, FOCUS 1.2+ tag compliance
- Identity Center Guide — module-specific deployment walkthrough