OnCallReady

Lesson 13.41 · Terraform: State & Modules · 13 min read

Designing modules other teams can use

In plain words

A good module is like a good kitchen appliance. A toaster has one knob and a lever: you choose how brown, and it handles heat, timing and safety for you. A bad appliance either has forty knobs that expose every wire, or is just a cable with a switch taped on, adding nothing. And once people have toasters on their counters, you can't move the lever to the back without annoying everyone.

For Terraform modules, the knobs are variables: few required ones, typed with object({...}) and optional(), validated with clear messages, safe defaults for everything else. The results are outputs keyed like the inputs (subnet_ids["app"]), not whole resources. Compose modules in the root, version them with semantic tags, and ship moved blocks when internals change.

The problem

You wrote a network module for your team. Now five other teams call it. One renames an input and breaks everyone. Another cannot turn off a feature it does not need. A third passes a bad address range and only finds out when Azure rejects the apply twenty minutes in.

A module used by other people is a small product. This lesson is about designing one that is easy to call correctly and hard to call wrongly.

What you need to know already:

When to write a module

A module is an interface: callers depend on its inputs, outputs and the addresses of its resources. Every interface is a cost, so write one when it pays:

Do not write one to wrap a single resource with the same arguments renamed (a "thin wrapper"), or just to organise files - file names do that. HashiCorp's rule of thumb: a module should raise the level of abstraction - callers say what they want ("a network with these subnets"), not every detail of how.

The input surface

The variables are what callers see first:

variable "name" {
  description = "Name of the virtual network, e.g. vnet-orders-dev."
  type        = string
  validation {
    condition     = can(regex("^vnet-[a-z0-9-]{3,60}$", var.name))
    error_message = "name must look like vnet-<workload>-<env>, lowercase."
  }
}

variable "address_space" {
  description = "CIDR ranges for the VNet."
  type        = list(string)
}

variable "subnets" {
  description = "Subnets keyed by name."
  type = map(object({
    cidr              = string
    service_endpoints = optional(list(string), [])
    nsg_rules         = optional(map(object({ priority = number, port = string, access = string })), {})
  }))
  default = {}
}

variable "tags" {
  description = "Tags added to every resource."
  type        = map(string)
  default     = {}
}

The subnets type in words: a map from subnet name to an object with a required cidr, an optional list of service_endpoints (Azure services the subnet may reach privately; default none), and optional firewall rules (nsg_rules, each with a priority, a port and allow/deny; default none).

What good looks like:

The output surface

output "vnet_id"    { value = azurerm_virtual_network.this.id }
output "vnet_name"  { value = azurerm_virtual_network.this.name }
output "subnet_ids" { value = { for k, s in azurerm_subnet.this : k => s.id } }

Composition over nesting

Composition means: the root module calls several small modules side by side and connects them, passing one module's outputs into another's inputs.

root (envs/prod)
 ├── module "network"   ->  ./modules/network
 ├── module "aks"       ->  ./modules/aks        (subnet_id = module.network.subnet_ids["aks"])
 └── module "keyvault"  ->  ./modules/keyvault   (principal_id = module.aks.identity_principal_id)

Here aks is a module for a container cluster (AKS, 13.25) placed in one of the network's subnets, and keyvault gives that cluster's identity (principal_id = the ID of an identity in Entra ID) access to a Key Vault. The wiring is all visible in the root.

The alternative, nesting - modules that call modules that call modules - is what to avoid:

Root -> module -> at most one more level. Each module stays small and testable, and the root reads like an architecture diagram.

Conditional pieces inside a module

A feature flag is a true/false input that switches part of the module on or off:

variable "enable_bastion" {
  type    = bool
  default = false
}

resource "azurerm_public_ip" "bastion" {
  count = var.enable_bastion ? 1 : 0
  # ...
}

output "bastion_public_ip" {
  value = one(azurerm_public_ip.bastion[*].ip_address)    # null when disabled
}

(A bastion is a managed "jump box": a single entry point for logging in to machines in a private network.)

For an optional nested block, the same trick with a dynamic block over a list of zero or one element.

Documentation and examples

Every module ships:

Versioning and releases

Public modules: Azure Verified Modules

Microsoft maintains Azure Verified Modules (AVM, 13.34) on the public registry: Azure/avm-res-<provider>-<resource>/azurerm for one resource type, Azure/avm-ptn-... for patterns of several.

Reading their source is the best way to see a mature module: many typed inputs, validation, logging, roles and locks built in, examples tested in CI, and an option to turn off the usage reporting they send to Microsoft.

Using them is a trade: less code for you, but a large interface you do not control, and upgrades on their schedule. Pin exact versions and read the release notes.

A checklist for reviewing a module

What you can now do:

Why it helps

On a platform team, your modules are a product other teams depend on. A painful interface means support tickets and teams copy-pasting around you; a leaky one means every provider upgrade becomes a breaking change for twenty callers. Situations: reviewing a module PR that exposes public_network_access_enabled raw instead of a safe default; a caller's plan wanting to replace resources after a minor bump because internals were renamed without moved blocks; deciding whether to adopt Azure Verified Modules. Interviewers ask "how do you design a reusable module?" to see whether you think in interfaces, versioning and the golden path.

FAQ

When is writing a module not worth it?

When it would just wrap one resource with the same arguments renamed, or only organise files. A module is an interface with a maintenance cost: callers depend on its inputs, outputs and addresses. Write one when a group of resources is needed repeatedly, must be built consistently (naming, tags, diagnostics, security), or forms a golden path for other teams. HashiCorp's rule: it should raise the level of abstraction.

Should module outputs return whole resources?

Usually not. value = azurerm_subnet.this exposes every internal attribute, so any provider upgrade or internal refactor changes your interface. Output what callers wire up, IDs, names, maps of IDs keyed like the input (subnet_ids["app"]), so callers never depend on ordering or internals.

Is it fine for modules to call other modules?

One level of nesting is fine; deep nesting hurts. Each level adds variables to thread through, produces addresses nobody can type, and turns a change at the bottom into a release of every layer. Prefer composition in the root: pass one module's outputs into another's inputs, so the root reads like an architecture diagram.

What counts as a breaking change for a module?

Removing or renaming inputs or outputs, adding a required input, changing defaults in a way that alters infrastructure, and changing resource addresses without moved blocks. Those need a major version bump under semantic versioning. New optional inputs and fixes are minor and patch. Keep a CHANGELOG, because callers read it before upgrading.

Should we use Azure Verified Modules or write our own?

AVM modules (Azure/avm-res-..., Azure/avm-ptn-...) are mature: typed inputs, validation, diagnostics, RBAC and locks built in, tested in CI. Using them means less code, but a large interface you don't control and upgrades on their schedule. A common approach is a thin internal module that calls an AVM module with your organisation's defaults. Either way, pin exact versions and read release notes. Their source is also the best reference for module design.

In an interview Mid

How do you design a Terraform module for other teams to use?

Treat it as a small product with an interface:

Also asked: What makes a good module input variable? · How do you roll out a breaking change in a module used by many teams? · Why should a module not contain a provider block?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.