Cloud Infrastructure · Infrastructure as Code

Module Interfaces and the Cost of Hiding Structure

Why a module boundary turns a graph into a black box, which abstractions leak through the interface, and how validation and outputs shape what callers can express.

A module is a directory of configuration with a declared set of inputs and outputs. The definition is unremarkable; the consequences are not. Inside the boundary, the resources form a graph the caller cannot see and cannot order against directly. Outside it, the module is a single node with a signature. Most of the design pressure in a large configuration comes from deciding where that boundary sits, and most of the disappointments come from the fact that the boundary is not opaque in the way a function boundary is.

Two properties distinguish a module from a function. It has no return value until the apply completes, and its internals are visible in state under addresses that include the module path. Both are consequences of the module being a naming convention over the same flat graph rather than a separate evaluation unit.

The interface is variables and outputs, and nothing else

Inputs are declared with a type, an optional default, and optional validation. Outputs are declared with a type and a value expression. Everything else (resources, locals, provider configurations) is internal. The caller cannot reference an internal resource by address unless the module exposes it as an output, and it cannot inject a dependency on an internal resource except by referencing an output that depends on it.

That last restriction is the important one and it is routinely violated by accident. A caller that needs to create something after a module’s internal resource is finished has only two options: consume an output that is derived from that resource, or add depends_on on the whole module. The first is precise and requires the module author to have anticipated the need. The second is a coarse edge to every resource in the module, at create and at destroy, and it makes the module’s internal parallelism irrelevant to the caller’s ordering.

An output that is not derived from the resource in question does not create the edge. Returning a static string from a module creates no dependency at all, even though the output looks like an interface. The edge exists where a value flows from a managed object, and the output is only a carrier.

Types constrain callers more than they document

A variable’s type is a constraint on the caller, checked before any API call. map(string) rejects a map with a numeric value; object({ name = string, size = number }) rejects a map with an extra key unless the type is written with optional attributes. These checks are cheap and they fail early, which is the best property a validation can have.

Validation blocks add predicates that the type system cannot express: a string matching a naming pattern, a number in a permitted range, a list whose length is a power of two. They run during plan, against the value the caller supplied, and they can only inspect that value. They cannot inspect the remote world, because doing so would require an API call at validation time and would make the module’s acceptance depend on mutable state.

The type is also where modules most often over-constrain. A variable typed as a concrete object with every attribute required forces every caller to supply values that have sensible defaults, and it prevents the module from growing an attribute without a breaking change. Writing the type with optional attributes and defaults, or typing loosely and validating the fields that matter, keeps the interface extensible. The trade is that a loose type moves errors from plan time to apply time, which is the wrong direction.

Abstraction leaks through replacement semantics

The most consequential leak is the one nobody declares: which attributes force replacement. A module that wraps a managed database exposes inputs for size, version, and network. The caller believes those are tunable. Some of them are, and some cause the provider to destroy and recreate the database, because the underlying API cannot change them in place. The module’s interface says nothing about which is which.

A caller who changes a version number and applies gets a plan that replaces the database. The plan does say so (the operation is visible) but the interface implied otherwise, and the review that approved the change was a review of a variable value, not of a lifecycle consequence.

The mitigations are partial. The module can expose the replacement-forcing attributes as separate, explicitly named inputs, so the signature carries the warning. It can set prevent_destroy on the resource, so an apply that would delete it fails and forces a deliberate decision. It can document the behaviour, which works only for readers. None of these make the interface honest in the way a type signature is honest, because the underlying fact (this API cannot modify this field) is not expressible in the configuration language.

Modules cannot be iterated the way resources can

A resource can carry count or for_each. A module can too, and the semantics are the same: each instance gets an index in its address, and the caller’s references index into it. The difficulty is that the module’s own internals are replicated per instance, and any resource inside the module that uses a name derived from a literal will collide across instances.

The module author therefore cannot make a module safely repeatable without parameterizing every name. A module that hard-codes a bucket name works exactly once. Making it repeatable means deriving the name from an input, which means every caller must supply a unique value, which means the interface grows a parameter whose only purpose is disambiguation. That parameter is a symptom of the module having been designed for a single call site.

The alternative is to keep the module non-repeatable and let the caller create one module instance per environment, with the environment name as an input. That is simpler and it moves the repetition to a level where the names are naturally distinct.

Where the boundary should sit

Three heuristics survive contact with real configurations.

The boundary should follow ownership, not technology. A module that groups everything about a network (the VPC, subnets, routes, and the associations between them) is coherent because those objects change together and are read together. A module that groups “all the compute” across unrelated services is a directory, not a boundary, and it will accumulate inputs that exist only because one caller needed them.

The boundary should be wide enough to hide a graph and narrow enough to expose the values callers need. A module with forty outputs has not hidden anything; it has moved the graph’s surface into a list. A module with two outputs usually forces callers to reach around it with depends_on or to duplicate resources.

The boundary should be stable relative to the churn inside it. The measure of a good module interface is how often it changes compared with how often its internals change. A module whose variable list is edited every time a provider adds a feature is not an abstraction; it is a pass-through.

Composition is reference, not nesting

Modules compose by one module consuming another’s outputs, not by one module being written inside another. Terraform has no nested module declaration; a module calls another module, and the resulting addresses are paths such as module.network.module.subnets.aws_subnet.this["a"]. The path is a namespace, and it is why moving a resource between modules changes its address and requires a move.

Because composition is by reference, the dependency graph spans module boundaries without any special mechanism. An edge from a module output to a caller resource is an ordinary edge. This is what makes the graph the right mental model at every scale: the same topological ordering that runs inside a small module runs across a configuration of a hundred modules, and the only difference is the depth of the address paths.

The cost of composition by reference is that the graph is only as good as the outputs. A module that computes an important value internally and does not export it forces the caller to recompute it, which duplicates logic and can diverge. Exporting a value that is cheap to compute and stable in meaning is almost always right; exporting one whose meaning changes with the internals is how a module’s interface becomes a promise it cannot keep. The same reasoning applies to provider configuration, which is passed down implicitly rather than as a value, and which is why a module that declares its own provider requirements can fail in ways the caller did not expect, the subject of provider versions and schema migration.

References

  1. Terraform Language: Modules — HashiCorp
  2. Terraform Language: Input Variables — HashiCorp
  3. Terraform Language: Output Values — HashiCorp