We still maintain a VPC module we wrote in 2022. It has been through four major AWS provider versions and is used by eleven teams. What kept it alive was not clever HCL. It was a stubbornly small interface and a hard rule that we never break a published input without a major version bump. Modules are not code you write once; they are contracts you have to honor for years.
The interface is the whole product
A module's variables and outputs are the only part anyone else sees. Get those wrong and no amount of internal refactoring saves you. The mistakes we see most: exposing 60 variables because the author could not decide what to opinionate, and passing whole provider-shaped objects through as inputs so every provider upgrade becomes a breaking change for consumers.
- Default to opinionated. A module that takes 8 inputs and makes 30 sensible decisions beats one that takes 50 inputs.
- Name inputs for intent, not implementation: `enable_private_endpoints`, not `subnet_ids_for_endpoints`.
- Outputs are an API too. Once a team depends on `output.cluster_endpoint`, you cannot rename it without breaking them.
- Avoid passing computed resource attributes as inputs to other modules unless you really mean to couple them.
A module that takes 8 inputs and makes 30 good decisions beats one with 50 knobs and no opinion.
Version like you mean it
Pin modules by Git tag or registry version, never by branch. We tag with semver and we are religious about what counts as a major: any removed or renamed input, any output rename, any default change that alters real infrastructure on the next apply. That last one bites people - bumping a default `instance_type` is a breaking change even though no variable changed shape, because somebody's plan now wants to replace a database.
module "network" {
source = "app.terraform.io/veritech/network/aws"
version = "~> 4.2"
cidr_block = "10.40.0.0/16"
enable_private_endpoints = true
}Saying no is part of the job
The fastest way to wreck a shared module is to accept every feature request as a new boolean. We have a module with a `legacy_mode` flag we regret to this day; it forked the internal logic so thoroughly that the two paths barely share code. Now when a team needs something genuinely different, we would rather they compose two smaller modules, or fork and own their variant, than bolt a tenth conditional onto the shared one. The shared module's job is to be boring and predictable for the common case.
One practical tell that a module has aged well: when a new provider version drops, you upgrade the module internals and cut a minor release, and not a single consumer has to touch their call. If a provider bump forces every caller to change their HCL, the interface was leaking implementation.