Every mature Terraform estate has the 4,000-line main.tf nobody wants to touch. Refactoring it into modules is high-value and high-dread, because the code change is the easy half — the hard half is moving state addresses so Terraform does not interpret your reorganization as "destroy everything and recreate it."
Tier: Advanced · Workflow area: Infrastructure as Code
Large-context AI assistants are genuinely strong at the code restructuring and at drafting the state-move plan; the discipline is that state surgery is generate-only, human-executed, and gated by a zero-diff plan.
Skip this on infrastructure scheduled for decommission, and never combine a refactor with functional changes — one intent per PR.
moved blocks — strongly preferred over CLI state mv).Map before you move. Have the assistant build the inventory first — it forces both of you to understand the blast radius:
Read main.tf, networking.tf, data.tf in this repo. Produce:
1. A table of every resource: address, type, and which logical group it
belongs to (network / cluster / database / iam / monitoring).
2. The dependency edges between groups (which references cross groups).
3. A proposed module decomposition that minimizes cross-module references,
with which variables/outputs each module boundary needs.
Do not write any code yet.
Review the decomposition like an architecture doc — module boundaries outlive this refactor.
Generate the refactor with addresses preserved via moved blocks.
Implement the approved decomposition. Rules:
- Resource CONFIGURATION must be byte-for-byte equivalent — this refactor
changes structure only, no attribute changes, no version bumps.
- For every resource that changes address, emit a moved block:
moved {
from = aws_subnet.private_a
to = module.network.aws_subnet.private["a"]
}
- Where count is replaced by for_each, enumerate every index-to-key move
explicitly.
- Emit the moved blocks in a single moves.tf so they are reviewable
together and removable after cutover.
Gate: the plan must show moves only. This is the entire safety argument:
terraform plan -out=refactor.plan
terraform show refactor.plan | grep -E "will be (destroyed|created|replaced)" && echo "STOP: not a pure refactor"
The acceptable plan says moved / Plan: 0 to add, 0 to change, 0 to destroy. Any create/destroy pair means a missed or wrong moved block — fix it before proceeding. Do not rationalize "it's just recreating a harmless resource"; harmless resources are how the habit of accepting destroys forms.
Cross-check the moves list independently. Diff the assistant's moves against reality: terraform state list before vs the planned addresses after. Every old address must map somewhere; count mismatches mean orphans that will be destroyed. This is the citation-check step — do not skip it because the plan "looked clean."
Apply during a freeze; verify zero-diff; clean up. Apply (human hands, state backup taken), then immediately terraform plan again expecting No changes. After a soak period, remove moves.tf in a follow-up PR.
For fleets: refactor one environment fully, extract the pattern, then have the assistant replay the same decomposition per environment — each with its own moves-only plan gate. Never batch-apply across environments on one review.
If the assistant proposes terraform state rm plus re-import as a shortcut, or offers to run state commands itself — refuse. moved blocks are declarative, reviewable, and roll back with git; imperative state surgery is neither, and an agent must never execute it.
aliases: [{name: oldName}]) play the moved role; agentic tools like Pulumi Neo can drive refactors — hold them to the same preview-shows-no-replaces gate.terraform state list output; expect more iteration on the moves list.terraform validate + TFLint; module boundaries expose only the variables/outputs the dependency map requires.No changes.moves.tf cleanup PR merged after soak.aws_instance.app[0] → aws_instance.app["blue"] needs an explicit move per index; assistants get the pattern right and individual indices wrong. The independent cross-check catches this.The full root module and state listing describe the client's entire architecture. This playbook should run inside the client boundary by default: in-tenancy AI endpoint (Bedrock/Azure OpenAI) for the mapping and generation steps, OSS chain for everything else. Where no in-tenancy model exists, the decomposition design (step 1) can be done on a structurally-redacted resource table (types + relationships, names hashed), with code generation done by hand or with a locally-hosted model.
No changes.