Designing Reusable Modules
A reusable module lives or dies on its interface. The inputs and outputs are a contract other teams build against, and a contract you got wrong is expensive to change once people depend on it — renaming an input is a breaking change to everyone who pinned it. This final topic pulls the chapter together into the principles that separate a module others adopt happily from one they fork in frustration.
The single best design rule is that a single-purpose module beats a do-everything one. A module that builds the Hatch ingest pipeline and nothing else is easy to reason about, test, and compose. A module that builds a pipeline, a GKE cluster, and a VPC behind forty boolean flags is unmaintainable, half its flags are mutually exclusive, and nobody can tell what a given call actually creates. Everything below serves that rule.
Interface Design First
Decide the inputs and outputs before you write the resources, because the boundary is the part you cannot change cheaply later. The resources inside are private and can be rearranged anytime; the input names and output names are the public contract. For the ingest-pipeline module the contract is event_name in, topic id and dataset id out — and once that boundary is right, the implementation can evolve underneath it for years without a caller noticing.
Sensible Defaults vs Over-Parameterizing
Default the things almost everyone wants the same, and only expose an input when callers genuinely differ. Region defaults to us-central1, BigQuery location to US, bucket retention to thirty days — so the common call is two arguments while the advanced caller can still override any of them. Every input you add is a maintenance cost and a new way to misuse the module, so the discipline is to keep the surface as small as the real use cases require and grow it only when a second caller actually needs a knob.
# a clean call: two arguments, everything else defaulted module "clickstream" { source = "./modules/ingest-pipeline" project_id = var.project_id event_name = "clickstream" } # the advanced caller overrides a default when they genuinely differ module "transactions_eu" { source = "./modules/ingest-pipeline" project_id = var.project_id event_name = "transactions" region = "europe-west1" # override only what differs bigquery_location = "EU" }
Do Not Expose Every Argument
A module that surfaces all fourteen of a bucket's arguments as inputs is just the bucket with extra steps — it adds indirection without adding value over calling the resource directly. Expose intent, not implementation: an input named enable_versioning = true hides the underlying versioning block and labels the decision the caller actually cares about. The module earns its existence by raising the level of abstraction, and re-exporting every low-level argument throws that away.
examples/ and README as the Contract
An examples/ directory with a working call is the real documentation and the basis of the module's tests. A README listing inputs, outputs, and one runnable example is what makes the module adoptable — a team should be able to copy the example, change the project, and apply. CFT modules all ship both, generated with terraform-docs for the input and output tables; copy the pattern. A module with no examples and no README forces every adopter to reverse-engineer the contract from the source, and most never bother.
Single-Purpose Beats Do-Everything
A module that creates an ingest pipeline is reusable; a "platform" module that creates a pipeline, a GKE cluster, and a VPC behind thirty boolean flags is a configuration nightmare. The flags interact — half are mutually exclusive — every call is a different shape, and no reviewer can tell what a given invocation produces. The fix is always to split it: build small single-purpose modules and compose them with the patterns from the previous topic, rather than one configurable monolith.
When you do need to change a published interface, treat it like the versioned contract it is. Renaming an input or removing an output after teams depend on the module is a breaking change; you bump a major version and document it in the CHANGELOG, exactly as CFT does. Interface design first, defaults for the common case, intent over implementation, examples as the contract, and single-purpose over monolith — those five carry the whole chapter, and the next chapter turns to identity and access on GCP, where the same contract discipline applies to IAM.
Single-purpose module — does one thing, like an ingest pipeline or a hardened bucket, with a small intent-shaped interface. Easy to reason about, test, and compose; a reviewer can tell what a call creates at a glance. Always prefer composing several of these.
Do-everything module — hides many unrelated resources behind a forest of boolean toggles. Every call is a different shape, half the flags conflict, and no reviewer can tell what it creates. The signal to split it is when you reach for a flag to turn a whole resource group on or off.
- Designing the interface around exactly one caller's needs, so the second consumer has to fork the module instead of calling it.
- Exposing every underlying resource argument as an input, so the module is the resource plus indirection and adds no value over calling the resource directly.
- Building one "platform" module with thirty flags for projects, networks, and pipelines — combinations become untestable and mutually exclusive, and nobody knows what a call produces.
- Shipping a module with no
examples/and no README of inputs and outputs, so adopters reverse-engineer the contract from the source and the module never gets used. - Renaming an input or removing an output in a minor version, breaking every consumer who pinned with
~>and expected no breaking change.
- Design the inputs and outputs (the contract) before writing the resources, since the boundary is the expensive part to change later.
- Default what callers usually share and expose only what they genuinely vary, keeping the interface small and intent-shaped.
- Ship an
examples/directory with a runnable call and a README of inputs and outputs — the same contract CFT modules document withterraform-docs. - Build single-purpose modules and compose them, rather than one do-everything module behind a wall of boolean flags.
- Validate inputs so misuse fails at the call site with a clear message, and follow SemVer so
~>constraints stay safe for consumers.
examples/ + README + versioned contract
Pulumi component design — the same interface-first discipline
Helm chart values.yaml design and the over-templating anti-pattern
Knowledge Check
Why design a module's inputs and outputs before its resources?
- The interface is the public contract that is expensive to change once teams depend on it, while the resources inside stay private
- Terraform requires variables and outputs to be declared in the file before any resources that use them
- It makes the module apply measurably faster, since resolving the interface contract first lets Terraform skip an entire planning pass
- Resources cannot reference variables unless those variables are written earlier in the same file
A module re-exports all fourteen of a bucket's arguments as inputs. What is wrong with that?
- It is the bucket with extra indirection — it adds no value over calling the resource directly
- Terraform caps a module at ten input variables, so the eleventh fails at validate
- Exposing that many inputs bloats the module and makes it noticeably slower to download
- Nothing — exposing every argument as an input is the recommended way to maximize caller flexibility
What is the trade-off of a do-everything "platform" module with thirty boolean flags?
- Flag combinations become untestable and mutually exclusive, and no reviewer can tell what a given call creates
- It is slower to apply but otherwise far easier to maintain than splitting it into several small, focused modules
- It works fine until it exceeds thirty-two boolean flags, which is Terraform's hard ceiling
- It cannot be published to the public Registry, but it remains fine for internal team use
What makes a module adoptable by a team that did not write it?
- An
examples/directory with a runnable call and a README of inputs and outputs, so they copy the example and apply - A large number of inputs covering every possible need, so the team never has to fork or extend the module
- Inlining the provider and credentials configuration inside the module itself so the adopting team needs no setup at all
- Omitting version constraints entirely so callers always pull the newest code on every init
You got correct