~/definitionOfDone.dev/blog/root-stack-should-be-boring.md

8 min read

The Root Stack Should Be Boring

Every serverless estate hits the same fork at about the same size. You start with one CloudFormation stack, it grows, and somewhere past forty or fifty resources you notice that a…

TL;DR

  • Past roughly fifty resources you'll split one stack into many. That creates a new problem: some resources can only exist once, and they need exactly one owner.
  • Give the root stack a single job — construct the shared resources and hand them out. No business logic, no service functions. It should change about four times a year.
  • Choose how services receive shared values deliberately. Nested stacks give you correct ordering and one blast radius. Exports give independence and a coupling you can't easily undo. SSM Parameter Store gives the loosest coupling and the least protection — usually the right answer past a handful of services.
  • One rule keeps the topology readable: services may depend on the root freely; a service depending on another service needs justifying.
  • The discipline erodes through parameter lists, not architecture decisions. Audit unused parameters in CI.

Every serverless estate hits the same fork at about the same size. You start with one CloudFormation stack, it grows, and somewhere past forty or fifty resources you notice that a one-line change to a single function now redeploys everything you own.

So you split it. And the moment you split it, you have a new problem that nobody warns you about: some things can only exist once.

A custom domain name. A shared Lambda layer. An authorizer. A log destination. A VPC endpoint. A WAF web ACL. These aren't per-service resources — they're per-estate resources, and the second you have eight stacks, you have to decide who owns them.

Get that decision wrong and you spend the next two years paying for it in ways that are hard to attribute back to the original mistake.

The three ways teams get this wrong

Every stack owns its own copy. Each service defines its own authorizer, claims its own subdomain, bundles its own shared code. It works immediately and it's obviously wrong at any scale. When the token format changes you have eight implementations to update, and you'll discover the ninth in production.

One stack owns everything, including business logic. The "core" stack accumulates the shared resources and the functions that felt too important to put elsewhere. Now your highest-churn code sits in the stack with your highest-blast-radius resources, and every deploy of a shared utility risks your custom domain.

Nobody owns them and they were made by hand. The domain was created in the console in 2023 by someone who has left. It's not in any template. It's not in any repository. It's discovered during an incident.

The third one is the most common, and it's the one that turns a two-hour outage into a two-day one.

What I'd do instead

Have a root stack. Give it exactly one job: construct the things that must exist once, and hand them to everything else.

Nothing else. No business logic, no domain functions, no queues that belong to a service. The root stack should be the least interesting file in the repository and it should change roughly four times a year.

Concretely, that means it owns:

  • The custom domain and its DNS record
  • Shared Lambda layers
  • A shared authorizer, if you have one
  • Cross-cutting log and trace destinations
  • VPC endpoints and shared security groups
  • Anything with a name that must be globally unique

And it exposes them to the services that need them.

This is the composition root idea from dependency injection, applied to infrastructure. The value isn't the wiring — it's the constraint. Once shared resources have exactly one owner, "who's allowed to create this?" has exactly one answer, and every service can only ask for things.

mermaidsnippet
flowchart TD
    R[Root stack<br/>shared resources only]
    R --> D[Custom domain + DNS]
    R --> L[Shared layer]
    R --> A[Authorizer]

    S1[Orders service]
    S2[Billing service]
    S3[Notifications service]

    D -.identifier.-> S1
    D -.-> S2
    D -.-> S3
    A -.ARN.-> S1
    A -.-> S2
    A -.-> S3
    L -.ARN.-> S1
    L -.-> S2
    L -.-> S3

The property this buys you is worth naming, because it's the one you'll actually feel: you can understand any single service by reading its own template plus the block that feeds it. You never hold the whole estate in your head to reason about one part of it. That's the difference between an estate a new engineer can join and one where only two people know how anything connects.

Now the hard part: how do services get the shared values?

This is where the real decision is, and it's usually made by accident.

There are three mechanisms, and they trade off along the same axis: how tightly do you want deployment coupled?

Nested stacks. The root declares each service as an AWS::Serverless::Application (or AWS::CloudFormation::Stack) and passes shared values as parameters. CloudFormation understands the whole graph and orders it correctly.

Exports and Fn::ImportValue. The root exports values; services import them. Independent stacks, but CloudFormation enforces the dependency — you cannot delete or modify an exported value while something imports it.

Indirection through SSM Parameter Store. The root writes ARNs to well-known parameter paths. Services read them at deploy time via {{resolve:ssm:...}}, or at runtime.

Here's how I'd actually choose.

Nested stacks give you correctness and take away independence. One sam deploy handles ordering, one changeset, one rollback. That's genuinely valuable — nobody has to know the deploy order. But it means a one-line change to one service redeploys the root and everything under it. Blast radius is the whole estate. A failure in the ninth nested stack rolls back the other eight. And you'll eventually hit the 500-resource-per-stack ceiling, which arrives faster than you'd think when every function brings a role, a log group, and a permission.

Use nested stacks when the estate is small enough that "deploy everything" is acceptable, and when you'd rather have coordination handled for you than have independence.

Exports give you independent deploys and a rigid coupling you'll come to resent. You cannot change or delete an export while anything imports it. That sounds like safety — and it is, right up until you need to replace the authorizer and discover you must remove every consumer first, in order, across twelve repositories. Exports are excellent for values that will genuinely never change. They are a trap for anything you might want to swap.

SSM Parameter Store gives you the loosest coupling and the least protection. Nothing stops you deleting a parameter something depends on. But it's the only mechanism that lets you replace a shared resource without touching consumers: write the new ARN to the same parameter path, and services pick it up on their next deploy.

That last property is why, for estates past a handful of services, this is usually where I land. The coupling becomes a contract on a name rather than a hard link — with the honest cost that you now need discipline and monitoring where CloudFormation was previously giving you a guarantee.

One thing worth knowing: {{resolve:ssm:...}} resolves at deploy time, so the value is baked into the deployed resource. Reading the parameter at runtime instead gives you the ability to change a dependency without redeploying — and buys you a per-invocation API call, a new failure mode, and a caching problem. Deploy-time resolution is the right default. Runtime resolution is for values that genuinely change without a deployment.

The rule I'd write on the wall

Services may depend on the root freely. A service depending on another service requires a conversation.

That sounds bureaucratic. It isn't — it's the single rule that keeps the topology comprehensible, because service-to-service infrastructure dependencies are cheap to add and brutally expensive to unwind.

It takes one line to make the billing stack read a queue name from the orders stack. That one line converts a star into a graph. Now those two stacks have a deployment order, can't be torn down independently, and the next engineer has to know about the relationship to reason about either one. Add four more of those over a year and you've rebuilt the monolith you split up, except now it's distributed and the coupling isn't in a file anyone reads.

Sometimes it's genuinely necessary. When it is, make it explicit and visible — a declared dependency the tooling understands beats a hardcoded ARN or a name assembled from a convention. A convention-built name (${env}-orders-events) avoids the deployment dependency, which looks like a win, right until someone renames the queue and nothing tells them what broke. Pick the failure you'd rather have. I'll take the deployment constraint, because it's visible before the change ships.

Where this discipline actually erodes

Not in a big architectural decision. In parameter lists.

Adding a parameter to a service is a two-line change in two files. Removing one requires proving nothing uses it. So parameter lists ratchet — they only grow. Give it eighteen months and you'll have services receiving values they never reference, and each unused parameter is a small lie: it tells the next reader that this service participates in something it doesn't.

Two habits fix this cheaply:

Audit unused parameters. A script that parses each template, collects declared parameters, and greps for references catches every one of them in seconds. Run it in CI.

Assume the plumbing is wrong and check it. The classic failure is a service template growing a required parameter that its parent never passes. Nothing catches this — not your editor, not the build, not review, because nobody compares a parameter list against a template they don't have open. It fails during the deploy, which in CloudFormation means it fails during the rollback. It's also entirely statically detectable, and worth a couple of hundred lines of script.

The test

Open your root template. If you can't describe what it owns in one sentence, it owns too much.

The root stack is infrastructure for your infrastructure. It should be short, stable, and dull enough that nobody has strong feelings about it. If it's the file people are nervous to change, the problem isn't the file — it's that you've put things in it that belong somewhere they can fail on their own.

older Build the Bridgehead Before You Migrate Anything