Directory names follow AVM-style <provider>-<kind>-<service>-<resource>:
azure-res-network-privatednszone
│ │ │ └── resource
│ │ └────────── service
│ └────────────── kind
└──────────────────── provider
| Segment | Values |
|---|---|
| provider | azure (azurerm), entra (azuread), fortios (FortiGate), github |
| kind | res, ptn, utl |
res— resource module. Wraps a single logical resource plus its tightly-coupled children. One deployable thing.azure-res-cache-redisis a Redis instance and its private endpoint, diagnostic settings, lock and role assignments — all meaningless without the instance.ptn— pattern module. Composes or fans out, usuallyfor_eachover a map input. Names commonly end-collection,-records,-memberships.utl— utility module. Pure computation. No resources, no providers, norequired_providersentries — justlocalsandoutputs.
FortiGate modules carry an extra segment: fortios-<kind>-fortigate-<service>-<resource>. Every
device-facing FortiGate module uses it. The one exception is fortios-utl-network-cidr, which is
pure arithmetic and touches no device.
Most pattern modules do not source a sibling res module — they declare provider resources inline,
deliberately. It keeps the pattern self-contained when it is copied into a Terragrunt cache, at the
cost of some duplication. fortios-ptn-fortigate-router-static-collection inlines even though a
sibling res module exists.
Don't "fix" an inlining pattern module into a delegating one without a reason beyond tidiness.
Every module directory contains these four files. They are never folded together:
| File | Contents |
|---|---|
main.tf |
resources, data sources, locals |
variables.tf |
inputs — one variable block each |
outputs.tf |
outputs, each with a description |
versions.tf |
terraform { required_version, required_providers } |
Provider version constraints live only in versions.tf. No provider blocks anywhere inside a
module — see Usage.
Three optional additions sit alongside them:
README.md— usage, Inputs, Outputs, Notes. Most modules do not have one yet, so don't assume one exists.examples/basic/— sources the parent withsource = "../../"and does declare aproviderblock. Onlyentra-ptn-group-collectionhas one today; treat examples as encouraged rather than established.modules/<child>/— nested submodules, same four-file layout.
Used when a child resource must be manageable independently of its parent — for example
entra-res-group/modules/member lets membership be managed separately from the group, which matters
when the two are owned by different configurations.
Submodule paths are addressable by git ref exactly like top-level modules. A submodule with no in-repo caller is not dead code — external consumers reach it directly. Never delete or re-address one based on in-repo reference count.
Collection inputs are maps of objects keyed by a stable identifier, using
optional(<type>, <default>) for non-required fields:
variable "records" {
description = "DNS A records, keyed by record name."
type = map(object({
ttl = optional(number, 3600)
records = list(string)
}))
default = {}
nullable = false
}The map key is the for_each address and the output key. Pick stable keys — renaming one
re-addresses that resource, and removing one from the middle of a map does not disturb its
neighbours only because the key, not the position, is the address.
Validation is heavy and expected: length bounds, enum contains(...), and regex can(regex(...))
checks with clear error_messages.
Two things to watch:
- Tautologies never fire.
condition = alltrue([for v in values(x) : v.scope != null || true])is always true. If a check needs a second variable, reference it directly — cross-variable validation is legal at therequired_versionevery module declares. - Don't validate enums you cannot verify. The
fortiosprovider does not publish its accepted values in the schema for many attributes. A guessedcontains(...)list rejects working configurations. Validate what you can prove.
The fortios family and github-res-repository (plus its three submodules) historically shipped
without description or validation. Descriptions have since been closed across the whole tree —
every variable and output now has one. Validation coverage is still uneven: the azure and entra
modules validate heavily, fortios has very little. New code should follow the azure/entra standard,
and touching an offender is a chance to close the gap.
A recurring Entra pattern: a map(string) accepts either an object ID (UUID) or a user principal
name, partitioned by regex, with UPNs resolved through an azuread_user data source at plan time.
It has a sharp edge — see Entra ID. When mirroring
this input into a ptn wrapper, copy the res module's object shape field-for-field.
Comments earn their place by explaining what the code cannot.
Write one for: why a lifecycle block exists, why an attribute is deliberately left unset, why
iteration order is forced, why a value is hardcoded rather than exposed, a provider bug being worked
around, an ordering dependency invisible in the graph.
Don't write: prose restating the declaration below it, parentheticals narrating the next line,
# Required above a variable with no default, or past-tense notes about code that no longer exists.
# =============================================================================
# TITLE IN CAPS
# =============================================================================
# Optional body — only when there is non-obvious behaviour to explain. When
# present, close it with a fourth rule.
# =============================================================================A banner is not a mandatory file header; roughly half the module main.tf files carry one. Add a banner
when you have something non-obvious to say. A title-only rule is fine as a section marker in a long
multi-resource file; what makes a banner noise is a body that restates the resource type.
Box-drawing characters (╔ ║ ╚) are not used anywhere in the tree. Don't introduce them.
Azure modules mirror AVM input shapes where an AVM equivalent exists — name,
resource_group_name, tags, role_assignments — then add what AVM lacks. Shape new inputs the AVM
way so a consumer migrating between the two isn't rewriting call sites.