Refactoring a Monolithic GIS Stack into Composable Modules
The monolith is recognisable long before anyone names it: one main.tf holding the PostGIS cluster, the raster bucket, the tile service, the load balancer and the CI role, one state file, and a terraform plan that takes four minutes and touches ninety resources to change a security group description. Splitting it is straightforward in principle and dangerous in practice, because moving a resource between modules changes its address in state, and an address change that Terraform does not understand is a destroy-and-recreate — of a database that holds authoritative geometry. This guide applies Module Design Patterns to an existing estate, within Spatial IaC Architecture and Fundamentals, and its central discipline is that no refactor step is allowed to produce a plan containing a replacement.
Where to cut, and why lifecycle is the right seam
The instinct is to split by resource type — all networking here, all storage there — and it produces modules that are always changed together, which is the opposite of the goal. Cut instead along rate of change and blast radius, because those are what a plan boundary is actually protecting.
For a spatial platform that yields four natural components. The data tier — cluster, parameter group, subnet group, backups — changes rarely and cannot be recreated without an outage and a restore. The storage tier — raster and tile buckets, lifecycle rules, keys — changes rarely and holds data that must survive everything. The serving tier — renderers, task definitions, load balancer, autoscaling — changes many times a week and is fully disposable. The access tier — roles, policies, security groups — changes at its own rhythm, often in response to incidents, and needs to be reviewable in isolation.
The test for a good seam is a question: if this module were destroyed and recreated, what would happen? For the serving tier the answer is a brief blip; for the data tier the answer is an outage and a restore. Two components with such different answers should not share a state file, because a single careless plan should not be able to propose both.
Prerequisites
Terraform 1.6 or later, which is what makes this refactor safe: the moved block records an address change declaratively, so the engine understands a relocation rather than seeing a deletion and a creation. Providers pinned as usual. A remote backend with locking and versioning, because several steps here rewrite state and the ability to roll back to a prior state version is the safety net. Finally, a clean plan before you start — refactoring on top of unresolved drift makes it impossible to tell whether a proposed change came from your restructuring or from the drift.
Add prevent_destroy to every stateful resource before the first move. It is not a substitute for care, but it converts the worst possible outcome — an accepted plan that replaces the cluster — into a hard error.
Step-by-step refactor
-
Freeze and snapshot. Announce a change freeze, take a database snapshot, and note the current state file’s version identifier. Every subsequent step is recoverable to this point.
-
Extract the least dangerous component first. Start with the serving tier. If something goes wrong with an address you got subtly wrong, the consequence is a recreated task definition rather than a recreated cluster, and you learn the mechanics on a cheap component.
-
Move code into a module directory without changing state. Physically relocate the resource blocks into
modules/serving/and reference them with amodule "serving"block. At this point the plan will propose to destroy and recreate everything, because every address changed fromaws_ecs_service.tilestomodule.serving.aws_ecs_service.tiles. Do not apply. -
Add
movedblocks until the plan is clean. Onemovedblock per relocated resource tells the engine the old address became the new one. Re-run the plan until it reports no changes. A plan that still proposes a replacement means an address is wrong or a resource was missed — fix it in code, never by editing state. -
Apply the no-op, then split state if required. Applying a clean plan writes the new addresses. If the component is also moving to its own state file, use
terraform state mv -state-out=against a copy, verify by planning the new configuration, and only then remove the resources from the original. Splitting state is the step where a rollback is most likely to be needed, which is why the version identifier from step one matters. -
Repeat outward, data tier last. Access tier, then storage, then data. By the time you reach the cluster you have performed the same procedure three times and the mechanics are no longer novel.
# Step 4: the plan proposes 12 replacements until these blocks exist.
# A moved block is a declaration that an address CHANGED — the resource is the
# same object, so the engine reads its existing state rather than recreating it.
moved {
from = aws_ecs_service.tiles
to = module.serving.aws_ecs_service.tiles
}
moved {
from = aws_lb_target_group.tiles
to = module.serving.aws_lb_target_group.tiles
}
# Counted resources move element by element, and the index must match exactly.
moved {
from = aws_ecs_task_definition.renderer[0]
to = module.serving.aws_ecs_task_definition.renderer[0]
}
# Guard rails on anything that must never be replaced by a mistaken plan.
resource "aws_db_instance" "postgis" {
# ... existing arguments unchanged ...
lifecycle {
prevent_destroy = true
}
}
Verification
After each step the acceptance criterion is identical and non-negotiable: terraform plan reports no changes. Not “only safe changes” — none. A refactor that relocates code should be observationally invisible to the cloud, and any proposed change is evidence that an address is wrong.
Beyond the plan, verify the estate directly after the data-tier move, because that is the one where an error is expensive: the cluster identifier is unchanged, SELECT postgis_full_version() answers, the snapshot list still shows the pre-refactor snapshot, and the tile endpoint still returns a tile. Then verify the new boundaries do what they were for — a change to a task definition should now produce a plan touching the serving module only, and if it still proposes changes across the data tier the seam was drawn in the wrong place.
Preventing recurrence
- Give each component its own state and its own pipeline. The monolith reforms if one apply still covers everything. Separate state, described in State Backend Selection, is what makes the boundary real rather than cosmetic.
- Keep the
movedblocks for at least one release cycle. Removing them immediately breaks any environment that has not yet applied, and environments are rarely as synchronised as they appear. - Enforce a replacement gate in CI. A pipeline rule that fails any plan replacing a stateful spatial resource without an explicit approval marker — part of the assertion set in Testing and Validation for Spatial IaC — makes the worst outcome unreachable by automation.
- Publish the components as versioned modules. Once the seams are stable, versioning them lets environments adopt changes independently instead of all at once.
Frequently Asked Questions
Can I use `terraform state mv` instead of `moved` blocks?
You can, and for splitting state across files you must. Within one state, prefer moved blocks: they are code, so they are reviewed, they apply automatically in every environment, and they leave a record of the rename. A state mv is an imperative action taken by one person in one place, and the next environment has no idea it happened.
What if the plan still proposes a replacement after adding a moved block?
Either the address is not exactly right — indices and keys must match character for character — or the replacement is genuine because an argument also changed. Read the plan’s reason line: it names the attribute forcing replacement. Never resolve this by editing state by hand.
Should I refactor and change behaviour in the same pull request?
No. Mixing them destroys the acceptance criterion, because you can no longer require an empty plan. Land the relocation as a no-op, then change behaviour in a separate request where the plan is small and reviewable.
How small should the modules be?
Small enough that a routine change plans in seconds and touches only what it should; large enough that a single logical change does not require coordinated applies across four modules. If two components are always changed together, the seam between them is costing more than it returns.
Related
- Module Design Patterns — the parent topic on boundaries and interface contracts
- Designing Module Interfaces for Multi-Tenant GIS Platforms — the interface rules the extracted modules should adopt
- State Backend Selection — splitting state once the seams are drawn
- Managing Terraform State Locks for Spatial Data — recovering when a state-rewriting step is interrupted