The mega-module trap
The first version of our platform modules did too much. One eks-platform module created the cluster, node groups, IAM roles, the VPC endpoints, and half the observability stack. It felt productive — one module block and you had a platform. Then the second team needed a slightly different node group topology, and the third team needed the cluster without the VPC endpoints, and every "small" option became another variable. Within six months the module had 70+ inputs and nobody could predict what a change would do.
The fix was boring and it worked: flat modules, one concern each, composed at the environment level. The networking module doesn't know compute exists. The cluster module takes subnet IDs as inputs; it never looks them up itself. Composition happens in a thin per-environment root that reads like a manifest:
module "network" { source = "…//vpc" version = "2.4.1" … }
module "cluster" { source = "…//eks" version = "3.1.0"
subnet_ids = module.network.private_subnets }
module "o11y" { source = "…//monitoring" version = "1.9.2" … }
State is a blast radius, not a database
Our second hard lesson: one state file per environment sounds tidy until terraform plan takes eleven minutes and two teams hit lock contention every afternoon. We split state along service boundaries within each environment — network, cluster, shared services, then one workspace per team's infrastructure. Plans dropped back under a minute, and more importantly, the blast radius of a bad apply shrank to the boundary of one state file.
Version pins are a feature, not paranoia
Every module reference is pinned to an exact version. Upgrades are pull requests that roll dev → qa → prod, with the plan output posted into the PR by CI. Two reviewers: someone from the consuming team, someone from platform. The team validates intent; platform validates that the change won't create a cascading failure. It sounds heavyweight — in practice it takes minutes, because the modules are small enough that diffs are readable.
What I'd tell you to steal
- One concern per module. If the name needs an "and", split it.
- Compose at the root, pass IDs explicitly — no data-source archaeology inside modules.
- Split state by environment and service boundary before plans get slow, not after.
- Exact version pins everywhere, upgrades as reviewed PRs with plan output attached.
- Ship a module template repo — the fastest way to keep standards is to make the right way the lazy way.