Modules people reuse instead of copying

The two failures are a module that wraps one resource and adds nothing, and a module that does everything and nobody dares change. A minimal interface, safe defaults and honest versioning are what separate them.

Open the modules directory of an infrastructure repository that has been running for three years and you usually find both failure modes side by side.

There is modules/s3-bucket, which takes a name and creates a bucket, passing through eleven variables to the resource unchanged. It adds nothing. Every consumer has to know the resource's arguments anyway, and when someone needs a feature the module does not expose, they add a twelfth variable.

And there is modules/application, which creates a load balancer, a cluster service, a database, DNS records, alarms and a pipeline, takes forty-three variables, and has a count on half its resources. Nobody modifies it, because the blast radius of a change is every environment at once.

A good module sits between these, and the distinguishing feature is that it encodes a decision.

A module should encode a decision, not wrap a resource

The test to apply before creating one: does this module represent an opinion your organisation holds that would otherwise be repeated?

A bucket module that only creates a bucket fails the test. A bucket module that creates a bucket with public access blocked, encryption with your managed key, versioning on, a lifecycle rule expiring noncurrent versions, access logging to your log bucket and a standard tag set passes it. The second one is not a wrapper, it is your standard expressed as code, and using it means a team gets the correct configuration by default rather than by remembering.

That is the real value: not saving typing, but making the compliant version the easy version. It is the same reasoning as the scaffolding argument in a developer portal is only as good as its catalogue.

The corollary is that premature abstraction is the more common error. Write the resources inline first. When the same pattern appears the third time, and you know which parts vary, extract a module. Two occurrences is a coincidence.

Interface: minimal inputs, safe defaults, composable outputs

Inputs. Expose the smallest set of variables that covers real variation. Every variable is a permanent commitment, because removing one is a breaking change. Use types properly, constrain with validation blocks where the value has rules, and mark secrets as sensitive.

Resist the pass-through variable. If a consumer needs to configure something your module does not expose, that is a signal either to add it deliberately or to conclude that this consumer should not be using the module.

Defaults. Every default is a decision made on behalf of everyone who uses the module, so make the safe one the default. Encryption on, public access off, deletion protection on, retention set, logging enabled. A consumer who needs the unsafe version can set it explicitly, and that explicit setting is visible in code review, which is exactly where you want that conversation.

Avoid defaults for things that should be a deliberate choice, such as the environment name or the instance size in production. A required variable forces a decision.

Outputs. Export what a consumer needs to compose with other modules: identifiers, ARNs or resource names, endpoints, the security group, the role. A module whose outputs are insufficient forces consumers to look up resources by data source, which couples them to your internals.

Do not output the whole resource object. That exposes every attribute and makes any internal change a potential breaking change.

Versioning, and what counts as breaking

Modules need versions, and versions need to mean something.

A change is breaking if it removes or renames a variable or an output, changes a default in a way that alters existing infrastructure, or causes a resource to be destroyed and recreated. That last one is the important one and the one people miss: changing a resource name inside a module, or restructuring a for_each, can produce a plan that destroys production. If you must, ship the moved blocks that tell the state how to follow the change.

Consumers should pin to a version, never to a branch. A module referenced by branch changes under its consumers without a pull request, which is the opposite of the point.

Publish through a registry if you have one, or by git tag if you do not. Tags are perfectly adequate; the registry adds discovery and documentation rendering rather than capability.

Keep a changelog. Nobody reads module source to work out what a version bump does.

The composition question

There are two schools, and mixing them produces the forty-three variable module.

Thin modules composed by the caller. Each module does one coherent thing, and a stack wires them together. More verbose at the call site, much easier to change, and each module is independently testable.

Thick modules that build a whole stack. One call produces an environment. Concise, and it becomes the thing nobody dares modify, because every consumer is affected by every change.

We default to thin modules with a small number of deliberate composite modules for genuinely standard patterns, such as a standard service deployment that five teams use identically. The composite ones get stricter review and slower release.

A related rule: a module should not create resources that outlive the thing it represents. A module that creates a shared VPC alongside an application makes the application's lifecycle control the network's, which surfaces the first time someone destroys a stack.

Documentation and testing, briefly

Generate the input and output documentation from the code with one of the standard tools, wired into CI so it cannot drift. Hand-maintained variable tables are wrong within two releases.

Include a working example directory. It is documentation people actually read, and it doubles as the fixture for tests.

Test at the level the module deserves. Validation and static analysis for everything, plan-based assertions for modules with real logic, and apply-and-destroy integration tests for the small number of foundational modules where a mistake is expensive. The pyramid is in testing infrastructure code.

The things people forget

  • Provider configuration belongs to the caller. A module that configures its own provider cannot be used twice in one configuration and cannot be used across regions.
  • Count and for_each on the module. Index-based count reorders on removal and destroys the wrong thing. Prefer for_each with stable keys.
  • Tags should be merged, not replaced. Accept a tag map and merge it with the module's own, or consumers cannot add their own.
  • Modules are a supply chain. A module pulled from a public registry runs in your pipeline with your credentials. Vendor or review the ones you depend on.
  • Deprecate explicitly. Mark old variables deprecated in the documentation and keep them working for a release rather than removing them and breaking everyone.

What to do this week

Take your most-used module and count its variables. Then check how many consumers set each one. Any variable set by every consumer should probably have no default and be required; any variable set by nobody should probably be removed. That single audit usually shrinks the interface by a third and makes the module easier to reason about immediately. We do this pass during the platform review in a cloud engagement.

ConsultorIA

Want this done on your cloud?

A ten-day read-only assessment is free, and Skyline lets you see your estate on a map before you write to us.

Related articles