When is writing a Terraform module premature abstraction rather than useful factoring, and how do you decide?
answer
- a module is an interface with a version
- does it hold a decision or just forward?
- one variable per argument is an alias
- wait for the second or third use
- un-wrapping later moves every address
basics
~20 sA module earns its place when it encodes a decision — defaults, naming, tagging, or several resources wired together — that would otherwise be repeated and drift. A module that wraps one resource and forwards every argument unchanged adds a version, an indirection and a release process while adding no behaviour.
solid answer
~50 sMy test is whether the module contains a *decision*. If it enforces a naming or tagging scheme, sets defaults that took an incident to learn, or wires several resources into a unit that is meaningless apart — a bucket with its policy, logging and lifecycle rules — then it is factoring, and the value grows with each consumer. If it wraps one resource and every variable maps one-to-one onto an argument, it is an alias: it hides the provider documentation, and the day someone needs an argument the module did not expose, they are blocked on a module change and a release rather than adding a line. I also wait for repetition before extracting; the second or third real use tells you which parts actually vary, and guessing that from one use almost always produces the wrong interface. Duplicating a resource block twice is cheap and reversible; a wrong module interface with six consumers is not.
go deeper
Know that a module is worth writing when the same infrastructure is genuinely needed more than once, and that a module wrapping a single resource with one variable per argument usually adds nothing.
Be able to name the concrete costs of a thin wrapper — arguments the module never exposed, provider documentation you can no longer read directly, and a version bump for every trivial change.
Show how you decide in a real repository: wait for the second or third use to learn what varies, prefer duplication while the interface is uncertain, and recognise that removing a module later moves every resource address.
Own the asymmetry and set the standard: over-abstraction is sticky and its cleanup is a state migration, while duplication is visible and local, so define what a module must encode to earn a version and where policy checks enforce conventions more cheaply than a module can.
## The question behind the question Interviewers ask this because most Terraform repositories fail in one of two directions, and both are expensive. One is copy-paste sprawl — the same bucket configuration in nine places, eight of them missing encryption. The other is over-modularisation — a private registry of forty modules, most of them one resource deep, where changing a tag means opening three pull requests in three repositories. Knowing how to write a module is table stakes; knowing when not to is the judgment being probed. ## The test: does it encode a decision? A module is an interface with a version, not a folder. It pays for that overhead when it holds knowledge: - **Enforced conventions** — naming, tagging, mandatory encryption, a required log destination. The module makes the safe thing the default and the unsafe thing impossible. - **Multi-resource wiring** — a bucket plus its policy, access logging and lifecycle configuration; a service plus its task role, log group and alarm. Those pieces have no meaning apart, and getting the wiring right once is real value. - **A decision that took an incident to learn** — a health-check grace period, a deletion protection flag, a subnet layout. Encoded once, it stops being rediscovered. If you cannot point at a decision, you have written an alias. ## The anti-pattern in detail The classic premature module wraps one resource and declares one variable per argument: ```hcl # modules/bucket/main.tf — an alias, not an abstraction resource "aws_s3_bucket" "this" { bucket = var.bucket force_destroy = var.force_destroy tags = var.tags } ``` It looks tidy and it costs more than it looks: 1. **Argument starvation.** The provider has dozens of arguments; the module exposes three. The first consumer who needs a fourth files an issue against the module and waits. 2. **Documentation indirection.** The provider docs no longer answer questions directly — a reader has to map every module variable back onto the real argument, and the names rarely match exactly. 3. **Release overhead.** Every trivial change is a module commit, a tag, and a version bump in each consumer before anyone sees it. 4. **Fake flexibility.** To reclaim what was lost, people add `dynamic` blocks and pass-through objects until the module is harder to read than the resource it wraps. 5. **A one-way door.** Un-wrapping later moves every resource address, so the cleanup is a state-moving refactor rather than a delete. ## Wait for repetition Extracting after the second or third genuine use is not laziness, it is information gathering. The first use cannot tell you which parts vary; you guess, and the guess becomes the interface everyone codes against. Duplicated resource blocks are ugly but local — anyone can change one without coordinating. A wrong module interface with six consumers must be changed in lockstep or versioned through a migration. There is a middle ground people forget: a shared `locals` block, a shared tfvars file, or a documented example in the repository often captures the convention with none of the versioning cost. ## The thin wrapper that does earn its place One module shape that looks trivial but is legitimate is the per-environment wrapper root: a small configuration per environment that sets the backend and provider, calls one stack module, and supplies that environment's values. It carries almost no logic, and that is the point — the environments differ only in inputs, and the difference is visible in one file per environment instead of being spread through the stack. Judge it by whether it stays thin: once a wrapper starts adding resources of its own, environments have quietly diverged and the shared stack no longer describes production. ## The direction of the mistake matters The two failure modes are not symmetric in cost. Copy-paste is visible and reversible: you can see the duplication and fold it into a module later. Over-abstraction is invisible and sticky — nobody proposes deleting a module, the layer accumulates consumers, and the refactor out of it moves state. If you are unsure, err toward duplication and extract when the third case arrives with evidence. ## What to say in an interview Give a test rather than a rule: name what a module must contain to be worth its version, name the pass-through wrapper as the anti-pattern, and mention the asymmetry — that the wrong module interface is much harder to undo than duplicated resource blocks. That is a lead's answer, and it is what the question is fishing for.
- What alternatives to a module capture a convention without the versioning cost?A shared `locals` block or a common tags variable in the root, a per-environment tfvars file, or a documented example directory that people copy. Static checks are a stronger option still — a policy rule that fails a plan creating an unencrypted bucket enforces the convention across every configuration, including ones that never adopt the module.
- How do you know an existing module has become premature abstraction?Look at its change log. If most commits just expose another provider argument, the module holds no decisions and is acting as a release gate on the provider. Other signals: consumers using `dynamic` blocks or opaque object variables to smuggle configuration through, and issues asking for a passthrough. That module is an alias and its consumers would move faster with the resource.
- When is a nearly-empty per-environment wrapper the right kind of thin module?When each environment is a small root that sets its own backend and provider, calls one shared stack module, and supplies that environment's inputs. Its thinness is the design: the only difference between environments is visible in one file. The warning sign is a wrapper that starts declaring its own resources — that means the environments have diverged and the shared stack no longer describes production.
A single-resource pass-through module is like a function that only forwards its arguments unchanged: it gives you a name to call and a place to look, but no behaviour, and every new parameter upstream has to be added to it by hand before anyone can use it.
saying these in an interview costs you the question
- Says every resource should be wrapped in a module for consistency
- Extracts a module from a single first use
- Treats duplication as always worse than indirection
- Ignores that un-wrapping later moves state addresses
- Adds dynamic blocks to make a wrapper pass everything through