TL;DR
- AI agents do not need modules. They have no evaluation engine - they search text. A module turns a literal value into a computation that cannot be found in the repository.
- Measured: in the modular version,
grepfor the target value returned 0 results, even though the plan computes it correctly. The flat version returned 1. - The cost of abstraction is bimodal. Community modules are enormous (a VPC module is ~36k tokens); "entity" modules are tiny - but produce three layers of wiring per resource.
- Worst finding: 169 git module references across the repositories, 0 pinned to a tag or SHA. Shared modules were meant to guarantee consistency - instead they make it impossible.
- Zero
terraform testfiles across five production repositories. Standards were nowhere enforceable, only recommended. - The fix: stop enforcing consistency through module inheritance; enforce it with contracts on plan output. Those work regardless of layout and survive AI-written code.
A question worth asking again
The rules for structuring Terraform were written when only humans wrote it. Modules, file splitting, layers of abstraction - all of it optimises for human reuse and for state separation.
Today a significant share of infrastructure code is written and read by AI. Sonar's 2026 State of Code survey puts 42% of committed code as written or assisted by an AI agent. So the question is fair: do those rules still hold when the primary reader of the code is no longer a person?
We did not answer that in theory. We built a controlled experiment, then measured five real production repositories across two clouds.
Method
We analysed five production Terraform codebases managed by one team: a static website on AWS, an AWS organisations landing zone, an application platform on AWS EKS, an application platform on Azure AKS, and a global network hub on Azure. All measurement was static - source code and saved plans only. No cloud calls, no infrastructure changes.
Anonymisation: repositories are labelled A-E. No organisation names, domains, IP addresses, subscription or account identifiers appear here. Every code sample in this article is synthetic.
How an AI agent actually reads Terraform
An agent has no language server, no symbol index, and cannot reliably evaluate an HCL expression in its head. It has three operations: glob for files, grep for a string, read a range of lines.
That has one consequence which never comes up in discussions about code structure: the value you are looking for often does not exist as text anywhere in the repository.
And Terraform has it worse than other languages. The peer-reviewed benchmarks agree:
- IaC-Eval (NeurIPS 2024): GPT-4 scored 19.4% on 458 AWS Terraform scenarios versus 86.6% on comparable Python tasks - roughly four times worse.
- DPIaC-Eval (FSE 2026): six frontier models reached 20.8-30.2% first-attempt deployment success on 153 real tasks, dropping to 8.4% once compliance checks were applied.
- TerraFormer (ICSE 2026, 17 models): the stated cause is that HCL is substantially under-represented in training data compared with the YAML and JSON of Kubernetes, CloudFormation, and Ansible.
An honest note on these numbers
The GPT-4 figure is from 2024 and today's models do better in absolute terms. The robust finding is not the absolute score but the relative gap between infrastructure code and application code. That gap is structural: less training data, no type system, and no way to verify correctness by running the code.
The experiment: what one layer of abstraction costs
We built two configurations that produce byte-for-byte identical
infrastructure. One is flat; the other has two levels of modules with
defaults in variables.tf. Then we asked the question agents actually
get asked in practice: "what CIDR does the prod private subnet get, and is
deletion protection on for the database?"
| Metric | Flat config | Modular (2 levels) |
|---|---|---|
grep for the CIDR |
1 hit | 0 hits |
| Files to read | 1 | 8 |
| Bytes to read | 557 | 2,265 |
| How the answer is obtained | read a literal | compute across 3 defaults |
Both produce 10.20.16.0/20 and deletion_protection = true. Only one lets you find it.
The modular version is better engineering by conventional standards. It is also the
version where an agent searching for text finds nothing and has to
derive the value through cidrsubnet(),
coalesce(), and contains() - the operation language
models are least reliable at.
Two constructs that turn abstraction into a trap
1. Defaults in a module's variables.tf. The effective
value lives in a file nobody reads at the call site.
2. Computed names and ranges (cidrsubnet,
format, concatenated identifiers). The result is undiscoverable -
literally zero hits, as measured.
Field measurement: five production repositories
An experiment is one thing; reality is another. Here are five codebases currently running in production:
| Repository | Cloud | Size | Resources | Module calls | Calls / resource |
|---|---|---|---|---|---|
| A - static website | AWS | ~8k tokens | 43 | 0 | 0.0 |
| B - landing zone | AWS | ~22k | 52 | 26 | 0.5 |
| C - application platform | AWS | ~48k | 113 | 11 | 0.1 |
| D - application platform | Azure | ~39k | 44 | 73 | 1.7 |
| E - global network | Azure | ~38k | 32 | 99 | 3.1 |
Size is an estimate in tokens (bytes ÷ 4). Resources = directly declared resource blocks.
Finding 1: the plumbing-to-substance ratio
The "calls per resource" column is the most useful number in the table. It tells you how much of the configuration is plumbing and how much is substance.
Repository E has 99 module calls and 32 directly declared resources - three layers of wiring for every real piece of infrastructure. Not because anyone got it wrong, but because it is the logical endpoint of "everything is a reusable module". This is exactly what people mean by spaghetti Terraform - except the usual diagnosis ("not modular enough") is precisely backwards.
Finding 2: the cost of a module is bimodal
Here the data surprised us, and it is worth saying out loud because it refutes the simplistic "modules are expensive". We measured the real size of the modules actually in use:
variables.tf alone - just the interfaceCommunity modules like VPC or EKS are enormous - the interface of one such module is larger than the entire infrastructure of a smaller project. The tiny "entity" modules in repository E, by contrast, add up to only 0.3× the root config; there the pain is not size but the number of hops.
So the right metric is not "modules yes/no" but how many tokens and how many hops it costs to answer one question. Both failure modes - the giant module and the forest of tiny ones - arrive at the same place from opposite directions.
Finding 3: shared modules with no immutable version
This is the most serious finding of the whole analysis. Repositories D and E draw modules from shared git libraries - precisely the mechanism meant to guarantee consistency between projects and global infrastructure.
Every reference took the form ?ref=master or pointed straight at a
development branch. That means your infrastructure can change without a
single line changing in your repository. Two plans run at different times
from an identical commit can produce different infrastructure.
For an AI agent this is fatal: it reads the module call, reads the module, and draws a conclusion that may be invalid tomorrow. But the agent is not the problem - a human has no way to determine what will actually deploy either.
The contrast that points to the fix
Repository C consumes community registry modules with version constraints
(version = "~> 5.0"). It is not an immutable pin, but it is an order
of magnitude better than a moving branch - and crucially it is
visible at the call site, so agent and human alike know what
they are working with.
Finding 4: zero enforceable contracts
Across all five repositories we found zero .tftest.hcl
files. The native test framework has shipped since Terraform 1.6 and can
validate a plan offline, with no cloud and no state.
The consequence: standards exist as an agreement and as code review, not as a rule. On a team where humans write the code, that hurts slowly. On a team where AI generates a large share of it, it stops working immediately - because the documented failure modes of generated IaC are exactly the ones code review skims past: missing encryption, over-broad permissions, deprecated attributes.
A decision rule for modules
The data supports a rule that is usable and independent of fashion:
"A module must be justified by repetition (used three times or more) or by a state boundary. Never by aesthetics."
| Situation | Recommendation | Why |
|---|---|---|
| Up to ~30 resources in one state | Flat files, literal values | A module adds hops and returns nothing. One file per concern. |
| Pattern repeats 3+ times | Module, but keep the tree flat | The alternative is copy-paste drift, which is worse. |
| Different lifecycle or blast radius | Separate state, not a module | A state boundary is a real boundary; a module is just syntax. |
| "To keep files short" | Don't | This is exactly how you get 3 calls per resource. |
| Thin wrapper over one resource | Don't | It raises no level of abstraction, only the file count. |
This is also HashiCorp's own guidance: keep the module tree flat, prefer composition over deep nesting, and do not write modules that merely wrap a single resource type.
Consistency without spaghetti code
This is the heart of it. Most teams try to hold consistency through source inheritance: every project imports the same module. The data shows where that leads - 169 unpinned references and a ratio of 3 calls per resource.
The working model is different: a shared module governs how configuration is written. A contract governs what it produces. Enforce the second one.
Layer 1: contracts on plan output
terraform test with command = plan runs offline, with no
cloud access and no state. Projects are then free to differ structurally, but
cannot differ on what matters:
# tests/guardrails.tftest.hcl
variables {
environment = "prod"
vpc_cidr = "10.20.0.0/16"
}
run "prod_database_is_hardened" {
command = plan
assert {
condition = output.db_settings.deletion_protection == true
error_message = "prod database must have deletion protection enabled"
}
assert {
condition = output.db_settings.backup_retention >= 30
error_message = "prod database must retain backups for at least 30 days"
}
assert {
condition = output.db_settings.storage_encrypted == true
error_message = "prod database storage must be encrypted"
}
}
run "dev_is_not_over_provisioned" {
command = plan
variables { environment = "dev" }
assert {
condition = output.db_settings.instance_class == "db.t4g.medium"
error_message = "dev should use the cheap instance class"
}
}
Verified in practice: both runs pass offline in seconds, with no credentials.
Layer 2: cross-project policy over plan JSON
One policy repository applied to every project's plan. This is the only consistency mechanism that survives differing structures, differing module versions, and AI-generated code - because it checks the outcome, not the authorship:
terraform show -json tfplan > plan.json
# example check over the resolved plan
conftest test --policy ./platform-policies plan.json
We verified this against a real plan: the check covered the whole infrastructure
without reading a single .tf file or knowing how the project was
structured. That is exactly what you need when every team has a different layout.
It also covers the documented failure modes of AI-generated IaC that
terraform validate and plan will never flag, by design:
wildcards in IAM policies, missing encryption, public ACLs,
0.0.0.0/0, and hardcoded values.
Layer 3: immutable module versions
The cheapest fix with the highest impact. A shared module without an immutable reference is not consistency - it is shared instability:
# WRONG - infrastructure changes with no change in your repository
module "role_assignment" {
source = "git::ssh://git@example.com/platform/modules.git//authorization/role_assignment?ref=master"
}
# WRONG - a work-in-progress branch referenced from production
module "subnet" {
source = "git::ssh://git@example.com/platform/modules.git//network/subnet?ref=fix/symlinks"
}
# RIGHT - immutable tag, visible at the call site
module "role_assignment" {
source = "git::ssh://git@example.com/platform/modules.git//authorization/role_assignment?ref=v2.4.0"
}
CI rule: reject the plan if any ref= is not a tag or a SHA.
Layer 4: give agents an oracle, not the source
Do not make an agent derive values - let it query them. This belongs in
AGENTS.md or CLAUDE.md in every infrastructure repository:
# Terraform: NEVER derive values by reading modules. Query them.
terraform show -json tfplan | jq '.planned_values' # every resolved value
echo 'cidrsubnet("10.20.0.0/16", 4, 1)' | terraform console
terraform console -var-file=prod.tfvars # verify an expression
# NEVER pipe a whole plan JSON into context - it is 4.5x larger than the
# HCL that produced it. Use a jq slice.
| Plan slice | Size | Tokens |
|---|---|---|
| Full plan JSON | 150,900 B | ~37,700 |
resource_changes only | 47,186 B | ~11,800 |
| Change summary (address + action + type) | 2,263 B | ~600 |
Measured on a real 20-resource plan. The gap between the full plan and the summary is 60×.
What to do this week
Ordered by return on effort
ref= on shared modules and pin them to tags. Add a CI check that rejects moving references.
.tftest.hcl contracts for the rules you actually care about.
AGENTS.md telling agents to query values via plan and console rather than derive them.
variables.tf to the call site, or expose them as outputs.
Frequently asked questions
Do AI agents need Terraform modules?
No. Modules optimise for human reuse and state separation, not agent readability. An agent searches text; a module turns a literal into a computation spread across files. They earn their place through repetition (three times or more) or state separation - never through aesthetics.
Should we refactor existing modular code?
Usually not - rewriting working infrastructure carries its own risk. The priority is pinning versions, adding contracts and policy, and exposing computed values as outputs. Dissolving modules is only worth it where calls per resource sits well above one.
Does this mean writing one giant file?
No. Splitting by concern (network.tf, iam.tf,
storage.tf) is ideal for agents - it supports targeted search and
partial reads. The problem is not the number of files but the number of
hops between them caused by abstraction.
How do you keep projects consistent with global infrastructure?
Contracts on plan output plus policy as code, not module inheritance. A shared module governs how configuration is written; a contract governs what it produces. Only the latter is enforceable across teams with different layouts.
Does AI remove the need for a platform team?
The opposite. The faster infrastructure code appears, the more it matters that someone owns the contracts, the policy, and the versioning. The role shifts from writing configuration to defining and enforcing rules.
Key takeaways
- Agents don't need modules, they need discoverable values. Measured: 1 hit versus 0 for the same fact.
- Module calls per resource is the best spaghetti detector. Above 1.0, the configuration is mostly wiring.
- The cost of abstraction is bimodal - giant community modules and a forest of tiny entities cause the same problem from opposite ends.
- A shared module without an immutable version is not consistency. 169 references, 0 pinned - and this was meant to be the main standardisation tool.
- Enforce outcomes, not structure.
terraform testand policy over plan JSON work across layouts and survive AI-generated code. - Zero tests across five production repositories is the normal state, not an outlier. That is an opportunity, not an embarrassment.
Related articles
Sources and method
- Original measurement: five production Terraform codebases (AWS and Azure), static analysis of source and saved plans, July 2026. Data anonymised.
- IaC-Eval (NeurIPS 2024) - benchmark of 458 AWS Terraform scenarios.
- DPIaC-Eval (FSE 2026) - 153 real-world tasks, six frontier models.
- TerraFormer (ICSE 2026) - evaluation of 17 models, analysis of HCL representation in training data.
- Sonar, State of Code 2026 - share of code written or assisted by AI.
- HashiCorp documentation: module development and composition,
terraform test, plan JSON format.
Want to know where your infrastructure stands?
We audit Terraform codebases - abstraction ratio, module versioning, whether your standards are actually enforceable, and readiness for AI-assisted work. You get a concrete list of actions, not a slide deck.
Book an Audit