Advanced Module Design
Advanced Module Design
Terraform modules are the primary unit of reuse and abstraction in any large IaC codebase. A beginner writes a module that wraps a few resources and calls it a day. A senior engineer at a company like Stripe or Airbnb designs modules like APIs — with deliberate interfaces, stable contracts, optional feature flags, and well-understood composition boundaries. This lesson teaches that second level.
The Module as a Published API
Every module you share across teams should be treated like a versioned library. Its input variables are the function signature; its outputs are the return values; its README is the documentation. Callers depend on the interface, not on the internals. This means you can refactor the inside of a module — swap an aws_lb for an aws_alb, change a naming convention, add encryption — without breaking every consumer, as long as the external variable and output contracts are preserved.
Discipline around this interface matters enormously at scale. At Google, internal Terraform modules published to the internal registry are subject to the same semantic versioning rules as any other library: breaking changes require a major version bump, callers pin to a version range, and upgrade is a deliberate migration, not an accidental side-effect of a teammate's commit.
Composition: Modules That Call Modules
The most powerful architectural pattern in advanced Terraform is composition — assembling small, single-purpose modules into larger ones that represent a deployable slice of infrastructure. Think of it like LEGO: a vpc module, a rds module, and an ecs-service module all stay thin and focused. A higher-level app-stack module composes them into a coherent deployment unit. A root module (your environment directory) then composes one or more stacks.
The key rule: each layer knows about the layer directly below it, not deeper. The root module instantiates app-stack. app-stack instantiates vpc, rds, and ecs-service. Those leaf modules manage raw AWS resources. No layer reaches across to a sibling or skips a level. This keeps blast radius small and refactoring safe.
Designing the Interface: Variables
A module's variable interface is where most design mistakes happen. Follow these rules to build interfaces that age well.
Use objects for grouped config, not a flat variable per field. Instead of fifteen separate var.enable_deletion_protection, var.backup_retention_days, etc., group logically related settings into a typed object. This keeps the calling module clean and lets you add new optional fields without changing every existing call site.
Declare explicit types. A variable typed as string versus object({ ... }) is self-documenting and catches mistakes at plan time rather than silently passing garbage. Use optional() inside object types (available since Terraform 1.3) to mark fields that have defaults.
Optional Features via Feature Flags
Real modules need to serve multiple use cases without becoming a different module for every use case. The professional pattern is to gate optional sub-resources behind boolean or object variables. When the flag is its zero-value (false or null), the resource count is zero — it does not exist. When enabled, it is created. Terraform's count and for_each meta-arguments make this possible.
null over false for optional objects. A boolean enable_alarm = false still forces the caller to provide all the alarm configuration fields (topic ARN, threshold). An alarm_config = null default means the caller provides zero fields unless they opt in. This makes calling the module significantly cleaner for the common case.
Outputs: The Contract with Callers
Outputs are not an afterthought. They are the interface through which parent modules and root modules consume your module's results. Export everything a caller could reasonably need: resource IDs, ARNs, DNS names, security group IDs. Do not expose internal implementation details (like intermediate locals or computed names that could change). Mark sensitive outputs with sensitive = true so values are redacted from plan output and logs.
Anti-Patterns to Eliminate
These patterns appear constantly in Terraform codebases at companies that grew fast without governance. Learn to recognize and fix them.
- The "God module": one module that provisions everything — networking, compute, database, DNS, IAM — for an entire environment. It has 200+ input variables, takes 45 minutes to plan, and is impossible to change safely. Fix: decompose into single-responsibility leaf modules composed by a thin root.
- Hardcoded values inside modules: a module that assumes
us-east-1, a specific AMI ID, or a specific account ID. It works for the author and breaks for every other team. Fix: make the value a variable; the caller provides context. - Passing full provider configs into modules: a module that takes
var.aws_access_keyand configures its own provider. This breaks provider aliasing, makes assume-role patterns impossible, and breaks the standard Terraform provider inheritance model. Fix: modules must never configure providers — that is the root module's job. - Treating
terraform.tfvarsas the only interface: large flat.tfvarsfiles with hundreds of loose variables, with no type enforcement. Fix: typed object variables with validation blocks.
count on a module that creates multiple distinct resources if order matters. If you use count = length(var.envs) on a module and later insert a new environment at position 0, Terraform will plan to destroy and recreate everything from index 0 onward. Use for_each with a map or set of strings instead — resources are keyed by the map key, not by position, so adding a new key only creates the new resource.
Putting It Together: A Calling Example
Here is how a root module instantiates the rds module designed above — clean, explicit, and easy to review in a pull request:
Pin to a specific Git tag (not a branch) in production module sources. A branch reference means a teammate's unreviewed commit can silently change what your next terraform apply deploys. Tags are immutable; branches are not.