DEV Community

Cover image for Domain-Driven Infrastructure: Organize Your Terraform by Reason to Change
Tomo Zayasu
Tomo Zayasu

Posted on

Domain-Driven Infrastructure: Organize Your Terraform by Reason to Change

One morning, a new engineer on the team asked me a simple question. "The Lambda for the new notification feature — does it go under modules/, or somewhere else?"

I didn't have a good answer.

We had a modules/lambda/ directory, so the obvious move was to put it there, and I nearly said so before something stopped me. The notification feature was part of the order workflow. Was this a reusable part, or a piece of the order domain? Two different questions were hiding inside one "where does it go?", and our directory structure couldn't tell them apart.

The conversation ended the way these conversations always end. "Let's just put it in modules/lambda/ for now."

The layout everyone uses

You've probably seen this structure. Most Terraform repositories look like it:

├── modules/
│   ├── vpc/
│   ├── ecs/
│   ├── rds/
│   ├── iam/
│   └── lambda/
└── environments/
    ├── dev/
    └── prod/
Enter fullscreen mode Exit fullscreen mode

It works. It plans, it applies, it looks organized. Nothing about it is wrong until the business asks for something.

"Ship the new feature." "Traffic doubled, scale it up." "Compliance changed, revisit the permissions." Each request is one business change. And each one sends you into vpc/, ecs/, rds/, iam/, secrets/, cloudwatch/. Different requests, same sprawl. One reason to change, six directories to touch.

Back when I worked this way, review time didn't go where you'd expect. Whether the change was correct was the easy part. The hard question was whether it was safe to apply, and nobody could answer that from the diff, so we asked whoever remembered what else depended on the security group being edited.

Software design has a word for this: low cohesion. Things that change together are stored apart. We'd never accept this in application code. We learned — from decades of work on cohesion, coupling, and separation of concerns — to keep things that change together in one place. Somehow that vocabulary never made it down to our infrastructure repositories.

This is not a Terraform problem. It is a design problem. And it deserves a design answer.

The name

I call the answer Domain-Driven Infrastructure.

Domain-Driven Infrastructure is the discipline of declaring domain boundaries in both code and execution — where a domain is a unit that shares a reason to change, an owner, a regulatory scope, and a risk boundary. In Terraform, the execution boundary is the state.

In one line: organize by reason to change, not by technology.

The phrase "a reason to change" is a deliberate echo of the Single Responsibility Principle. This is not a new invention. It is an old principle, finally applied to the layer that runs everything else.

Two clarifications before we go further, because the name invites two reasonable objections.

This is not DDD's infrastructure layer. In Domain-Driven Design, the "infrastructure layer" means persistence, messaging, logging: the technical plumbing beneath the domain model. Domain-Driven Infrastructure is about something else: the cloud infrastructure itself. VPCs, clusters, databases, IAM. The real kinship is with DDD's strategic design. Bounded contexts say: align business boundaries with system and team boundaries. This discipline takes the same move and pushes it one layer down, into the physical boundaries of your infrastructure code and state.

And this is not what your tools already do. That one deserves its own section.

What the tools already solve

If you use Terraform seriously, you likely use a stack orchestrator: Terragrunt, Terramate, or something similar. Terragrunt shipped its 1.0 release in 2026, after nearly a decade of solving real problems: keeping configuration DRY, managing remote state, orchestrating dependent runs. Teams that split their infrastructure by domain already exist, too. I'm not claiming to have invented something nobody else does.

Here's what I am claiming. These tools answer "how do I split and wire up state?" Domain-Driven Infrastructure answers "where should the boundary be, and why?" An orchestrator will happily manage stacks carved by technology (vpc, ecs, rds) or stacks carved by domain. It has no opinion. The boundary is your decision, and the tools are silent on it. This discipline is about that decision.

One honest tension, though. Terragrunt's dependency block reads outputs straight from another module's state, and that is the idiomatic way to wire runs together. It makes cross-state references easy, and easy things become defaults. This discipline asks you to choose those references deliberately, and mostly to avoid them, copying only the few values a domain truly needs. Never read another domain's state directly. That is a deliberate difference in philosophy, not a bug on either side.

Terramate approaches the same wiring differently. Its outputs-sharing feature, still experimental, collects a producer's outputs and supplies them as generated inputs, so the consuming configuration never references the producer's state directly. That is mechanically close to what this discipline argues for. When a tool independently converges on your position, that is worth noting.

The tools give you the how. What has been missing is a discipline for the where.

Splitting directories is not splitting

Suppose you agree, and you reorganize. You move payment resources out of the app directory into sensitive/payment/. Separate directory, separate concern. Done?

Not yet. If both directories are still applied from the same root module, Terraform sees one unit. One plan contains both. With a locking backend, they contend for the same lock. One failed apply hits both. You moved the files; you did not move the boundary. The boundary that actually matters is the unit of terraform apply: the state.

I split state by environment once and thought that was the boundary. It holds up on day one. What it doesn't survive is operation: the state grows a little every week, until plan and apply are slow enough that you schedule around them, and a diff from another team turns up inside a change you thought was yours. The thing that got expensive wasn't the infrastructure. It was the time to run it and the time to review it.

State is where your design becomes physical. Resources in one state are planned together, locked together, and applied together. Resources in separate states are not. A directory boundary is a promise; a state boundary is a fact.

Be precise about what that buys you. Separate states do not stop failures from propagating through the running system: break the network domain and everything on it breaks, whatever your state layout. What separation shrinks is the amount of infrastructure participating in a single Terraform change. That is the boundary you actually get to design.

So the discipline has three layers:

Business boundaries, code boundaries, execution boundaries — change stays local only when all three align.

When they align, the payoff is concrete and measurable: one business change touches one directory; the plan is small enough to read; the review needs one owner, not four; and a bad apply drags less unrelated infrastructure in with it. That is the blast radius of a change, contained. Four payoffs, one underlying fact: the reach of a change is local at every layer.

Modules are not the enemy

One misreading to head off. Organizing by domain does not mean abandoning reusable modules. Modules and domains answer different questions. A module answers how do we build this kind of thing. A domain answers why does this exist, and what makes it change. A domain calls modules; it does not replace them.

apps/
└── order/
    ├── lambda.tf        # calls modules/lambda
    ├── database.tf
    ├── iam.tf
    ├── alarms.tf
    └── backend.tf       # its own state
Enter fullscreen mode Exit fullscreen mode

The reusable module holds the implementation. The domain owns the reason to change. Confusing those two roles is one of the reasons technology-shaped repositories get hard to navigate.

Finding the domains

"Fine. But where do I cut?"

Domains are discovered, not decided. You don't draw them on a whiteboard; you find them in how your system already changes. Two questions expose them.

First, lifecycle: when, why, and with what does this change? Resources that always change together, for the same business reason, belong together, whatever AWS service they happen to be.

Second, cohesion: does this unit map to something the business would recognize? "Orders." "Payments." "Search." If a directory name means nothing to a product manager, it's probably a technology label, not a domain.

Applied to a mid-sized e-commerce system, the discovery tends to produce something like this:

├── platform/        # network, DNS — SRE-owned, slow-changing
├── apps/
│   ├── catalog/
│   ├── order/
│   └── search/      # each team-owned, independently changing
├── sensitive/
│   ├── payment/     # PCI DSS — regulation sets the lifecycle
│   └── personal/
└── audit/           # audit requirements, own cadence
Enter fullscreen mode Exit fullscreen mode

Notice what counts as a domain here. Not just business features. platform is a domain because it has one owner and one lifecycle. sensitive/payment is a domain because regulation, not the product roadmap, decides when it changes. A domain is wherever a reason to change, an owner, a regulatory scope, and a risk boundary coincide. Each of these directories is also its own state.

And to be clear: this does not make technology-based grouping always wrong. VPCs, DNS, org-wide IAM: sometimes the technical boundary is the ownership boundary, and cutting there is correct. The failure mode is making technology the only organizing principle.

Relationships are designed

Domains are discovered. Relationships are designed.

Once states are separate, this book organizes the relationships between domains into three kinds. Independent: no connection, the default you should fight to keep. Dependent: one domain needs a handful of values from another, such as a VPC ID or a DNS zone. Copy those values across as explicit inputs, review them like the contract they are, and keep the surface minimal. Isolated: for payment and personal data, design the absence of connection. No shared state, no shared pipeline, no casual reference. Isolation should be enforced by structure, not by policy.

The connections between domains are not plumbing. They are your architecture, declared.

Beyond Terraform

This article uses Terraform as its vehicle. The discipline is not confined to it. Pulumi stacks, AWS CDK stacks, Crossplane compositions. The mechanics differ, and the question survives every one of them: does your business boundary reach all the way down to the execution boundary?

It also survives the current wave of platform engineering. Platform engineering asks how to run an internal platform. This discipline asks where its boundaries belong. You will need both.

The question

Your directory structure declares how you understand the business. That's true whether you designed it or not. A repository organized by technology declares: "we understand our system as a set of AWS services." A repository organized by domain declares: "we understand our system as the business it serves." Both are declarations. Only one of them helps the next engineer who asks, at nine in the morning, "where does this go?"

So look at your repository and ask: where does ease of change live here? Which boundary protects it? What would it take for one business change to touch one directory, one plan, one owner?

Tools change. Organizations change. The question does not.

This article covers the core idea. The book goes further: how to find domain boundaries from the way a system actually changes, how to decide where state should split, how to connect split states without reading each other's state, how to migrate an existing repository one domain at a time, and where this design does not apply.

Domain-Driven Infrastructure: Organizing Terraform by Reason to Change is available on Amazon.

Top comments (0)