Skip to main content

code-organization-patterns


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 own versions.tf, tests/, and examples/.
  • 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, the source block in a project can be overridden to point at the local modules/ path (see ADR-012).
  • .github/workflows/ uses the single-responsibility pattern: cost estimation runs in infracost.yml, not inside ci.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

SituationApproach
Upstream module covers 80%+ of the use caseWrap — add enterprise defaults on top
Upstream uses incompatible provider version or awscc providerFork and rebrand (ADR-007 fork pattern)
No suitable upstream exists for the domainBuild custom following module structure pattern (Pattern 5)
Upstream is unstable or unmaintainedBuild 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

PrefixIntentAWS ResourcesSuitable For
mvp-Minimal viable — fewest variables, zero optional config< 5Quickstart, unit test fixture
poc-Proof of concept — demonstrates a specific feature5–15Feature validation, integration test
production-Full enterprise configuration15+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

FactorS3 Native Locking (chosen)DynamoDB Locking (rejected)
Terraform version>= 1.11.0 requiredAll versions
Resource overheadZero (no extra resource)DynamoDB table per state bucket
CostS3 object storage onlyDynamoDB read/write capacity
Operational surfaceSingle S3 bucketS3 bucket + DynamoDB table + IAM for both
Lock file format.tflock object in same bucketItem 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:

  1. All workspace state lives in a shared backend — a corrupted workspace can affect all environments.
  2. terraform.workspace interpolations make code harder to read and audit.
  3. Separate tfvars files + 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

ConstraintValueRationale
required_version>= 1.11.0S3 native locking (use_lockfile) requires 1.11.0; check blocks and optional() require 1.5+
AWS provider lower bound>= 6.28Minimum version with stable aws_ssoadmin_application and aws_identitystore_* resource coverage
AWS provider upper bound< 7.0Prevents silent breakage from major-version API changes; reviewed and bumped deliberately
awscc providerNot declaredADR-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:

  1. Update the lower bound in versions.tf for all affected modules.
  2. Run task build:validate to confirm all modules validate.
  3. Run task test:tier1 to confirm snapshot tests pass.
  4. Update CHANGELOG.md with the semver bump.
  5. Raise a PR for HITL review (ADR-015 root VERSION file gate).

Pattern Quick Reference

PatternADRKey File(s)Gate Command
Repository LayoutTaskfile.yml, docker-compose.ymltask ci:quick
Wrapper PatternADR-007NOTICE.txt, main.tftask govern:legal
Example NamingADR-005examples/ directorytask build:validate
State ManagementADR-006backend.hcl.exampleterraform init -backend-config=backend.hcl
Module StructureADR-001versions.tf, variables.tf, outputs.tf, locals.tftask build:validate
Environment SeparationADR-008*.tfvars, global_variables.tftask sprint:validate
Provider ConstraintsADR-003versions.tftask build:validate

See Also