Module Design and Reuse
Structuring infrastructure modules so they are genuinely reusable — interface design, versioning, testing, and knowing when not to abstract.
Infrastructure modules follow the same economics as any shared library: a good one saves every team that uses it; a bad one becomes a tax that every team pays and nobody can remove. The difference is almost entirely in interface design.
What a module should be
A module should encapsulate a complete, meaningful unit of infrastructure with an opinion — not a thin wrapper around a single resource.
flowchart TB
subgraph "Too thin — no value added"
A["module 's3_bucket'<br/>= one resource + passthrough variables"]
A --> A1["Users must still know<br/>every detail. Pure indirection."]
end
subgraph "Right level — an opinion"
B["module 'private_data_bucket'<br/>bucket + encryption + versioning<br/>+ TLS-only policy + access logging<br/>+ lifecycle rules"]
B --> B1["Caller states intent.<br/>Compliance defaults are built in."]
end
subgraph "Too broad — unusable"
C["module 'application'<br/>network + compute + data + DNS<br/>+ monitoring + IAM"]
C --> C1["Every team needs one exception.<br/>Interface grows to 80 variables."]
end
The middle example is the useful shape. It exists so that a team asking for “a private bucket for customer data” gets encryption, TLS-only access, versioning, and logging without knowing that those are separate concerns — which is exactly how organizational standards get enforced without a review board.
Interface design rules
Inputs express intent, not implementation. environment = "production" is
better than fifteen variables the caller must set consistently to mean
production. Push the mapping from intent to detail inside the module.
Sensible defaults for everything optional. If a module has twelve required inputs, most callers will copy someone else’s invocation without understanding it. Require only what genuinely varies.
Outputs are the contract. Export the identifiers other modules need — ARNs, endpoints, security group IDs — and treat removing an output as a breaking change.
No escape hatches that leak internals. A raw_extra_config passthrough is
tempting and destroys the abstraction: callers depend on internal structure, and
you can never refactor. When a caller genuinely needs something the module does
not offer, add it to the interface properly or let them not use the module.
Never hard-code environment specifics. Account IDs, region names, and CIDR blocks belong in the caller’s configuration.
Composition over configuration
When a module accumulates conditional behaviour, that is a signal to split it.
flowchart LR
subgraph "Configuration explosion"
X["one module<br/>enable_replica · enable_backup<br/>enable_multi_az · enable_encryption<br/>enable_proxy · enable_iam_auth"]
X --> X1["2^n untested combinations"]
end
subgraph "Composition"
Y1[database module] --> Y4[service stack]
Y2[backup module] --> Y4
Y3[replica module] --> Y4
Y4 --> Y5["Each piece tested independently.<br/>Callers compose what they need."]
end
A rough rule: when a boolean input changes what resources exist rather than how one resource is configured, it probably wants to be a separate module.
Versioning and release discipline
Modules must be versioned and callers must pin. An unpinned module reference means a module change deploys to every consumer on their next apply — which is a fleet-wide change with no rollout control.
- Semantic versioning, honestly applied. Anything that changes an existing interface, removes an output, or forces resource replacement is a major version.
- Resource replacement is a breaking change even if the interface is unchanged. A refactor that causes a database to be destroyed and recreated is the most dangerous kind of “minor” release.
- Publish a changelog that states the plan impact, not just the code change. “Adds tag propagation — expect an in-place update on all instances” is what consumers need.
- Deprecate with a window. Mark old inputs deprecated, warn on use, remove after a stated period.
Testing modules
Untested infrastructure modules are shared risk. A workable ladder, cheapest first:
| Level | What it catches | Cost |
|---|---|---|
| Static analysis / lint | Syntax, style, obvious misconfiguration | Seconds |
| Policy checks on the plan | Security and compliance violations | Seconds |
| Plan assertions | “This change should not replace the database” | Seconds |
| Apply into a sandbox, assert, destroy | Whether it actually works | Minutes to tens of minutes |
| Upgrade tests | Whether v1 → v2 is non-destructive for existing users | Minutes |
The last one is the most valuable and the most often skipped. Apply the previous version, then upgrade in place, then assert that no resource was replaced. This catches the failure mode that hurts consumers most.
A workable repository layout
modules/
network/ # VPC, subnets, routing — changes rarely
data-store/ # managed databases with backup + encryption defaults
service-runtime/ # compute + autoscaling + load balancer + logging
observability/ # dashboards, alerts, log routing for a service
environments/
dev/
main.tf # composes modules, dev-scale inputs
staging/
production/
policies/ # policy-as-code rules applied to every plan
tests/
Two things to note. Environments are separate directories with separate state, not workspaces switched by a variable — the isolation is the point. And environments differ only in inputs, not in which modules they use; otherwise staging stops predicting production.
When not to build a module
- Used once. Two call sites is the earliest reasonable point to abstract, and three is safer. Premature abstraction locks in the wrong interface.
- The abstraction would be thinner than the thing abstracted. A module that passes ten variables to one resource adds indirection and nothing else.
- The callers genuinely need different things. Forcing two divergent use cases into one module produces a module with a conditional for every difference. Two modules that share nothing are cheaper than one that shares everything badly.
Adoption checklist
- Modules encapsulate a complete unit with built-in organizational defaults.
- Inputs express intent; optional inputs have defaults; there are no raw passthrough escape hatches.
- Modules are semantically versioned and all callers pin a version.
- Resource replacement is treated and communicated as a breaking change.
- Every module has plan-level tests and at least one apply-and-destroy test.
- Upgrade tests verify non-destructive version transitions.
- Environments share modules and differ only in inputs.
Last updated 19 Aug 2026, 00:00 UTC.