Terraform Module Boundaries: What Actually Belongs in a Module

Last week we settled on module repos as the building blocks of a Terraform platform. That leaves the harder question: once you’ve decided everything lives in its own repo, what actually goes inside one? Where’s the line between “this is one module” and “this should be two”?

I’ve drawn that line wrong more than once, so let’s go through how I think about Terraform module boundaries now.

The specificity test for terraform module boundaries

A module should be specific. That means one resource, or a small group of resources that share the same purpose, ownership and lifecycle.

That’s an important distinction, because resources being deployed together doesn’t necessarily mean they belong in the same module.

If you can’t describe what a module does in a single sentence without using the word “and” more than once, it’s probably two modules pretending to be one.

An App Service and a dedicated App Service Plan, where your platform deliberately uses one plan per workload, fine, those can belong together. They share a lifecycle and you’re treating them as a single deployment unit.

But that doesn’t mean an App Service Plan always belongs inside an App Service module. If your platform allows several applications to share the same plan, suddenly that plan has its own lifecycle and its own consumers. The boundary has moved.

An App Service and a SQL database bolted together “because most apps need one anyway” is an even clearer example. That’s a decision being smuggled into infrastructure, and it’ll bite you the first time someone needs the App Service without the database, or wants the database managed separately.

The question isn’t just “are these normally deployed together?”

It’s also:

  • Do they change together?
  • Are they owned by the same people?
  • Do they have the same lifecycle?
  • Do they sit inside the same security and privilege boundary?

If the answers start diverging, you’ve probably found the edge of the module.

Helper modules: the reusable layer

Not every module is meant to be deployed on its own. Some exist purely to be called by other modules, so you’re not rewriting the same logic in every repo that needs it.

Private endpoints are a good example.

Almost everything in an Azure landing zone ends up needing one: storage accounts, key vaults, SQL, App Services, Service Bus, you name it. The actual target resource changes, but a lot of the surrounding plumbing doesn’t.

So rather than rebuilding that logic in every module, you can have a reusable private endpoint helper that creates the endpoint, connects it to the right subnet, associates the appropriate private DNS zone and configures the DNS zone group.

Importantly, that doesn’t mean the helper owns the DNS zone itself.

In a mature hub-and-spoke platform those private DNS zones will often already exist centrally and be linked to the appropriate networks. The helper’s job is to connect a workload to that platform capability, not recreate the platform every time somebody deploys a storage account.

That’s another useful module boundary in itself: consuming shared infrastructure and owning shared infrastructure are different things.

RBAC role assignments can fit into this category too, but with a caveat.

Wrapping azurerm_role_assignment in another module purely so you don’t have to write azurerm_role_assignment isn’t much of an abstraction.

The helper earns its place when it enforces something.

Maybe your platform only permits a defined set of built-in and custom roles. Maybe scopes have to follow a particular pattern. Maybe you validate the principal type, enforce naming conventions, or prevent certain assignments being made directly to users.

At that point you’re not hiding a Terraform resource. You’re encoding a platform rule.

There’s enough nuance in doing RBAC well, built-in versus custom roles, scope, assignable principal types and how all of that fits with PIM, that it deserves its own post rather than a paragraph here, so I’ll come back to it properly in a future one.

The test for a helper module is slightly different from the specificity test above.

A helper isn’t just judged by “does one thing.”

It’s judged by:

“Is this logic genuinely reusable, and does centralising it give me something better than copying the resource block?”

If two consumers need slightly different behaviour, that might mean you need a variable.

If every consumer needs slightly different behaviour, you probably don’t have a reusable helper at all.

Golden path modules: opinionated by design

A golden path module is different again.

It’s not trying to represent one individual Azure resource. It represents one recognised deployment shape, done the way your platform has decided it should be done.

If you’re building a golden path for a web application, that golden path might compose an App Service, private endpoint, RBAC configuration, monitoring and whatever else your organisation considers mandatory.

But it isn’t supposed to be a menu containing every possible way an application could ever be built.

This is the bit I got wrong early on.

I was trying too hard to make a single golden path cover everything, and every edge case I thought of turned into another optional variable.

Before long I had a module with a dozen toggles, half of which existed for combinations nobody had actually asked for, and the module had stopped being a golden path at all.

It had become a generic infrastructure factory with training wheels, which defeats the entire point.

There’s a rule from Elon Musk’s engineering algorithm, described in Walter Isaacson’s biography, that I keep coming back to here: delete aggressively, and if you don’t eventually have to add some of what you removed back in, you probably didn’t delete enough.

It sounds backwards, but the logic holds here too.

Strip a golden path down to what it’s actually meant to support.

If something turns out to be genuinely required, add it back deliberately.

If nothing misses it, it probably never belonged there in the first place.

The fix for my modules wasn’t adding more flexibility.

It was taking it away.

If a variant is genuinely common enough to matter, it earns its own golden path or a deliberate, narrow option inside an existing one.

Everything else gets left out.

And if someone needs something the golden path doesn’t cover, that’s a conversation, not automatically a new variable.

Where it’s still okay to bend

That doesn’t mean a golden path can never have an option.

The test is whether the variation represents a real, recognisable deployment shape, not an accommodation for “just in case.”

A web application might legitimately support deployment with or without a database because both are real patterns in your environment.

But that doesn’t necessarily mean the database itself should suddenly be buried inside the web application module.

This is where composition matters.

The golden path can decide whether the database module is called without making the database part of the App Service module.

That’s a subtle difference, but an important one.

The App Service module remains responsible for the App Service.

The database module remains responsible for the database.

The golden path is responsible for deciding how those building blocks are assembled into something useful.

That’s a much cleaner boundary than one enormous module full of resources controlled by enable_this, disable_that and maybe_create_the_other.

A rough shape

Nothing here needs full code to make the point, so here’s the rough shape these Terraform module boundaries take in practice:

module "web_app" {

  source = "..."

  # web application configuration

}

module "private_endpoint" {

  source = "..."

  target_resource_id = module.web_app.id

}

module "rbac" {

  source = "..."

  scope = module.web_app.id

}

module "database" {

  count  = var.include_database ? 1 : 0

  source = "..."

  # database configuration

}

The individual modules still have clear boundaries.

The web app module owns the web app.

The private endpoint helper owns the logic for connecting resources into the private network.

The RBAC helper applies the platform’s access model.

The database module owns the database and can have its own lifecycle, permissions and configuration.

Then the golden path composes them into the deployment shape you’re actually offering.

That’s the bit I think matters most.

A golden path isn’t an excuse to make a bigger module. It’s the layer that composes smaller, deliberately bounded modules into an opinionated outcome.

Get those boundaries right and the modules remain understandable, testable and reusable.

Get them wrong and eventually you end up with the module everyone is scared to change because nobody knows which combination of its twenty-seven optional variables somebody is relying on.

Next week I’ll cover calling conventions: how these modules actually get invoked and composed together once you’ve got the boundaries right.

Your subscription could not be saved. Please try again.
Your subscription to deniscooper.co.uk has been successful.

Join Cloud Engineers Who Build Smarter on Azure

Free checklists, practical guides and the latest posts — built for cloud engineers working with Azure and Terraform.

Leave a Comment