Module Inputs and Outputs
A module's input variables are its contract in and its outputs are its contract out. Together they are the entire public surface of the black box — nothing else crosses the boundary. The caller passes inputs in the module block, the module does its work privately, and the caller reads results back through outputs. Designing those two surfaces well is most of what separates a module that is pleasant to use from one that is painful.
For the Hatch ingest-pipeline module, the contract in is small — a project, an event name, a region, a BigQuery location — and the contract out is the handful of values another module or the root will wire to: the raw bucket name, the topic id, the Cloud Run URL, the service-account email. Get those two lists right and the module composes cleanly; get them wrong and every caller pays for it.
Inputs Are the Contract In
The variable blocks in the child are the arguments a caller must or may supply. Each one's type, default, description, and validation together define what a valid call looks like. A variable with no default is required — the caller has to decide it — and one with a sensible default keeps the common call short. That choice, required versus defaulted, is the heart of interface design: it decides what a one-line call to the module looks like.
variable "project_id" { type = string description = "GCP project the pipeline lands in." # no default → required } variable "event_name" { type = string description = "Event type; all resource names derive from this." validation { condition = can(regex("^[a-z][a-z0-9-]{2,28}$", var.event_name)) error_message = "event_name must be lowercase, 3-29 chars, hyphen-separated." } } variable "region" { type = string default = "us-central1" # sensible default → optional, common call stays short }
The validation block on event_name earns its keep: a bad value fails at the call site with a clear message, instead of failing deep in the apply when a bucket name turns out to be illegal. Typed, validated inputs move errors from minute ten of an apply to second one of a plan.
Outputs Are the Contract Out
output blocks expose computed results so the caller — and other modules — can wire to them without knowing the resource names inside. The ingest-pipeline module outputs the raw bucket name, the Pub/Sub topic id, the Cloud Run URL, and the service-account email. A caller reads them as module.ingest_pipeline.topic_id, with no idea whether the topic resource is named events or ingress inside the child. An output is also exactly how one module hands a value to the next — the topic id flowing into a downstream loader module is one module's output becoming another's input.
output "topic_id" { value = google_pubsub_topic.events.id description = "Pub/Sub topic id downstream loaders subscribe to." } output "enrichment_api_key" { value = google_secret_manager_secret_version.key.secret_data sensitive = true # redacted in plan and CI output }
Encapsulation: Everything Else Is Private
Resources, locals, and data sources inside the module are invisible to callers except through outputs. This is the encapsulation from the previous topic made concrete: the module can compute a bucket name from event_name in a local, look up the project number with a data source, and create a service account — and none of that is reachable from outside. The caller sees a small, declared surface, and the module keeps the freedom to change everything behind it.
Designing the Boundary as Intent
Expose intent, not implementation. A caller should set event_name = "clickstream" and let the module derive the bucket name, the topic name, the dataset id, and the service-account id from it. The wrong design forces the caller to pass fourteen low-level arguments — bucket name, topic name, dataset id, retention days — which is just the underlying resources with extra indirection. An intent-shaped interface is what makes the module add value over calling the resources directly.
This is why required inputs should be few and meaningful. The pipeline genuinely needs to know its project and its event type; everything else has a sensible default. Region defaults to us-central1, BigQuery location defaults to US, retention defaults to thirty days — so the common call is two arguments, and the advanced caller can still override any of them.
Sensitive Outputs and Interface Stability
Mark any secret-bearing output sensitive = true so Terraform redacts it in plan output and CI logs. Returning the enrichment-api-key value as a plain output prints it in every plan and every pipeline run — a quiet credential leak. The sensitive flag costs one line and closes that hole.
Finally, treat the interface as something other teams build against. Adding an optional input with a default is safe — existing callers are unaffected. Renaming an input, removing one, or dropping an output is a breaking change that forces every caller to edit. That asymmetry is why you output generously up front: adding an output later is cheap, and reaching into a module you forgot to expose is impossible.
Required input — a variable with no default. The caller must supply it, and Terraform errors at plan if they forget. Use it for the values the module cannot guess: project_id, event_name. Forgetting one should stop the run, not silently do the wrong thing.
Defaulted input — a variable with a default. Optional, so the common call stays short. Use it for the values almost everyone wants the same: region, BigQuery location, retention. Give a default only when a wrong-by-omission value is acceptable, never to project_id.
- Outputting nothing useful, then forcing callers to hardcode the resource names the module created — the module is a dead end you cannot compose with.
- Exposing every internal argument as an input "to be safe" — the contract balloons to thirty variables and the boundary stops meaning anything, a trap covered fully in Topic 47.
- Returning a secret like the
enrichment-api-keyvalue as a non-sensitive output, so it prints in plan logs and CI output for anyone to read. - Giving
project_ida default so the module silently provisions into the wrong project when a caller forgets it — required inputs should have no default. - Leaving inputs untyped (
any), so misuse fails deep in the apply with a cryptic provider error instead of at the call site with a clear one.
- Give every input variable a
type, adescription, and avalidationwhere a bad value has a clear shape, so the contract documents itself. - Output every value a caller could plausibly wire to — bucket name, topic id, SA email, dataset id — because adding an output later is cheap and reaching in is impossible.
- Mark any secret-bearing output
sensitive = trueto keep it out of plan and CI logs. - Express inputs as intent (
event_name) and derive resource names inside the module, rather than making the caller name every resource. - Add new inputs as optional with defaults to keep the interface backward-compatible; treat renaming or removing one as a breaking change.
variables and outputs
Helm chart values.yaml as the same configuration surface
Knowledge Check
What is the entire public surface of a module?
- Its input variables and its outputs — everything else (resources, locals, data sources) is private
- Every resource and data source it declares, each addressable from the caller by its full module path
- Its state file, which the caller reads directly to pull out resource attributes
- The
providerblock it declares, which the caller configures and reuses
A module creates resources but declares no outputs. What is the consequence?
- Nothing can wire to its results, so it cannot be composed — it is a dead end
- Callers automatically receive every resource attribute exposed as an implicit output
- Terraform refuses to apply a module that declares no output blocks
- The module runs faster, since there are no output values to compute
Which change to a published module's interface is safe for existing callers?
- Adding a new optional input with a sensible default
- Renaming an existing input variable to something clearer
- Removing an output that turned out to be unused internally
- Making a previously optional input required
What does marking an output sensitive = true do?
- Redacts its value in plan output and CI logs, keeping a secret out of the run record
- Encrypts the value at rest in the state file so it cannot be read from the backend
- Prevents other modules and the root configuration from reading the output value at all
- Forces the caller to supply the value as a required input variable
You got correct