Skip to content

← Archive

Make the Easy Thing the Right Thing

When you start copying infrastructure code for the third time, you've already waited too long. A note on extracting shared patterns into a library, and being kind to future-you.


20 May 2026 6 min read

Note, August 2026. This opens by describing my own site as Angular SSR on Lambda, which was true when it was published. The site has since been rebuilt with every route prerendered to static HTML on S3 and no origin server at all. What follows about the shared library is unchanged, and one of the client sites still runs the SSR path it provides.

A while ago I deployed my own site. The architecture was deliberate: Angular SSR running on Lambda behind CloudFront, a CDK pipeline that deploys itself, S3 for static assets, the works. It took longer than a static site would have, every decision had a reason, and the result was something I was happy to maintain.

Then I started building sites for people in my life, and I caught myself doing what software engineers always seem to do when they think nobody's watching. I copied files.

The First Sign

For the second site I copied the Lambda configuration from my own. Same Web Adapter layer, same memory size, same env vars. Then the CloudFront distribution, then the Route 53 records, then the bucket deployments with their cache-control strings. By the time I had a working second deploy I had three hundred lines of nearly-identical CDK code sitting in two places.

It's tempting to feel productive about this. Look how fast the second one went, and how it already worked because the first one already worked. This is the kind of productivity that looks great today and becomes a liability tomorrow. The cost of copy-paste isn't the second instance. It's the moment the two start to drift.

A third site was on the horizon. If I copied again I'd end up with three subtly different versions of the same infrastructure, and every fix I made in one place would have to be remembered and re-applied in the other two. AWS keeps moving. A Lambda Function URL permission requirement changes here, a Web Adapter layer version bumps there. Three near-twins cost three times what one twin costs to maintain.

So I stopped and extracted.

What Goes Into the Library

This is where the design lives. Get it wrong and you either build something nobody else can use, or something so flexible it doesn't save anyone any work.

What ended up shared:

  • The full request flow: CloudFront, S3 origin with origin access control, Lambda Function URL origin with the IAM dance that makes it actually work in late 2025 after AWS changed how OAC permissions are granted, the viewer-request function that injects x-forwarded-host.
  • The cache strategy. Hashed assets get one-year immutable. Unhashed assets get a day with must-revalidate. SSR HTML defers to whatever the origin emits in Cache-Control.
  • The security headers. HSTS, framing, referrer policy, the Permissions-Policy header. Everything that should be the same on every site I run.
  • The Route 53 alias records and the apex-to-www redirect handled at the edge by a CloudFront Function.

What stayed in the consumer:

  • The Content Security Policy. Every site has different third parties. One needs an image CDN, another needs reCAPTCHA, another needs a newsletter embed. You can't predict a CSP in a library, so the library takes it as a string and trusts the caller to construct it.
  • Additional behaviours. One site has an API behind the same distribution on the /api/* path. The library accepts a map of extra behaviours and slots them in before the static catch-alls.
  • Additional domain aliases. One site has a future subdomain that needs to point at the same distribution.
  • The pipeline glue. Each project has its own source repos and its own build commands. The library exposes a helper for the post-deploy invalidation step and doesn't try to own the pipeline itself.

The line I drew: anything that should be the same on every site goes in the library, and anything that's a property of the site, meaning what data sources it connects to, what other behaviours it has, what domains it serves, stays in the calling code.

The library has knobs. Not many. Enough that the variations between three real-world sites are expressible, and few enough that the configuration isn't harder to write than the inline code was.

The Migration Was Different From the Greenfield

The two new sites adopted the library straight away. There was nothing deployed to conflict with, so CFN created the resources cleanly with the library's natural logical IDs. The interesting case was the original, a site that was already live, with a Distribution under a CFN logical ID derived from where it sat in the construct tree at the time, and a cache policy with an account-global name attached to it.

CFN doesn't follow rename instructions. Change a logical ID and it sees a new resource being created and an old resource being deleted, and it does the create first. If the new resource has an account-global name like a CloudFront cache policy, or if the new distribution wants the same alias as the existing one, the create fails and the changeset rolls back.

That left me two options for the migration. I could pin the old logical IDs in the new code so CFN treated the library's resources as in-place updates rather than create-then-delete, or I could delete the existing stack first and let the next pipeline run recreate everything fresh.

I went with the second. The first approach worked, and it left a residue in the code, a permanent block of "this resource was named differently before we used the library." I'd rather take an hour of downtime on a personal site than carry that residue forever. The library-based code is now the same on every site, the migration was a one-time event, and the result is consistent.

This is a small principle I keep coming back to. One-time costs are cheap and permanent costs compound. Given the choice between an hour of downtime and a permanent block of migration code that future-me has to understand every time he touches this file, the hour is the better trade.

The Reward

The boring, useful kind.

When AWS releases a new version of the Lambda Web Adapter, I bump the default in the library, run the build and publish a patch release, and every site picks it up on its next deploy. When I notice that the static behaviour list doesn't include .webp, I add it once and every site starts caching .webp correctly.

There's a smaller and more subtle reward too. I can reason about the system now. With three copies of inline CDK code, "is the cache policy the same on every site?" was a question I had to answer by reading three files and squinting. With one library the answer is yes, by definition. The shape of the system is in one place and the variations are explicit.

I don't believe in "always extract on the second use" or "always inline until the third". Rules like that are recipes. The actual question is whether the duplication is going to cost you: two copies of a five-line helper that's never going to change is fine, and two copies of three hundred lines of infrastructure code that talks to a moving cloud provider isn't.

The signal I trust is noticing myself fix something in one place and make a mental note to remember to fix it in the other. At that point the maintenance burden has overtaken the convenience of copy-paste, and the longer I wait the more painful the extraction gets.

You're not building a public library when you do this, and you're not optimising for hypothetical users. You're being kind to future-you, who's going to want to make a small change six months from now and would prefer to make it in one place. That's the entire goal. Make the easy thing the right thing.