~/definitionOfDone.dev/blog/layer-is-a-distributed-dependency.md

7 min read

A Lambda Layer Is Not a Library

Lambda layers look like libraries. You put shared code in a directory, functions import from it, you change it once and everyone gets the change.

TL;DR

  • A library is resolved at build time and frozen into an artefact. A layer is resolved at deploy time, versioned by the platform, and mounted into functions built separately — so at any moment your functions run an assortment of versions determined by deployment history, not by anything you declared.
  • That makes it a deployed component with a rollout problem. Treat a layer version bump as a deployment: environments, a changelog, an owner.
  • Layer version numbers are a monotonic counter with no semantics. Pinning is legitimate; pinning without recording why is how a temporary freeze becomes permanent.
  • Decide contents by asking "what am I willing to deploy to every function at once?" — not "what could be shared?" Pure, stable utilities qualify. Database drivers and protocol stacks generally don't.
  • Two layers built from the same source against different runtimes turns a runtime migration into a per-service decision with a one-line revert. Cheapest migration mechanism I know.
  • The real reframe: the layer isn't the shared code. It's the release channel for the shared code.

Lambda layers look like libraries. You put shared code in a directory, functions import from it, you change it once and everyone gets the change.

That mental model is wrong in a way that costs people real incidents, and it's worth being precise about why.

A library is resolved at build time and frozen into an artefact. Two services depending on different versions of the same package is completely normal, and neither one knows the other exists. The dependency is declared in a manifest, locked in a lockfile, and visible in a diff.

A layer is resolved at deploy time. It's versioned by the platform with an incrementing integer you don't control. It's mounted into functions that were built separately, possibly months ago, and haven't been redeployed since. Publishing version 47 changes nothing anywhere until each function is separately updated to point at it.

Which means at any given moment, your functions are running a mixture of layer versions — and that mixture is a property of your deployment history, not of anything anyone wrote down.

That's not a library. That's a deployed component with an API contract and a rollout problem.

Design the switch, not just the layer

The first decision worth making explicitly is one most estates make by accident: does a deploy build a fresh layer from source, or bind to a version that already exists?

Both are legitimate. Building fresh gives you coherence — everything deployed together runs identical shared code. Binding to a published version gives you independence — you can deploy a service without minting a new layer version for the whole estate.

Most teams have exactly one mode, whichever the pipeline happens to do, and to get the other one you edit the pipeline. Better to make it a parameter:

yamlsnippet
Parameters:
  PinnedLayerArn:
    Type: String
    Default: ""          # empty means "build from source"

Conditions:
  BuildLayerFromSource: !Equals [!Ref PinnedLayerArn, ""]

# ...and where the service consumes it:
Layers:
  - !If [BuildLayerFromSource, !Ref SharedLayer, !Ref PinnedLayerArn]

Note the empty-string sentinel. CloudFormation parameters have no null — every value is a string — so "unset" has to be spelled as some agreed value. That's not a hack, it's the shape of the tool, and it's worth a comment so the next reader doesn't think it's a bug.

The reason I'd bother: "am I coupling this deployment to the current state of the shared code, or pinning it to a known one?" is a question you genuinely want to be able to answer differently in different situations. A hotfix to one service at 2am should not be minting a new version of the library that eighty functions share.

There's a related trap worth flagging. If your local development configuration pins a specific layer version — and it usually does, because you need something concrete to run against — that integer drifts further from reality with every deploy. Six months later you're developing against a snapshot of the shared code from a version nobody remembers choosing, and eventually you'll ship a "works on my machine" bug whose mechanism is invisible.

Pinning is fine. Silent pinning is not.

You'll end up pinning individual functions. Something is fragile, something is high-stakes, something broke last time and nobody wants it picking up shared changes automatically. That's a reasonable position — automatic adoption of a shared dependency across hundreds of functions isn't obviously safer than deliberate adoption.

The problem is what a pin looks like in practice:

yamlsnippet
Layers:
  - !Sub "${SharedLayerArnNoVersion}:384"

Three hundred and eighty-four. A magic integer meaning "the state of the shared code at some moment nobody recorded."

Compare that to how you'd express the same intent with a package manager: a version range, a lockfile with a resolved version, and a changelog explaining what changed between them. All three are missing here. Layer versions are a monotonic counter assigned by the platform. They carry no semantics at all. You cannot look at 383 and 384 and know whether that was a typo fix or a breaking rename.

So the rule is simple: if you pin, record why, next to the pin.

yamlsnippet
Layers:
  # Pinned at 384. Version 385 changed the response helper's default headers
  # and this function relies on the old behaviour. Unpin after PROJ-1182.
  - !Sub "${SharedLayerArnNoVersion}:384"

Thirty seconds of typing, and it's the difference between a decision and an artefact. Without it, six months later nobody can distinguish a deliberate freeze from an oversight — and the safe assumption, "leave it alone", is exactly how a temporary pin becomes permanent.

The runtime migration trick

Here's a mechanism I'd reach for again. Declare the layer twice, from the same source directory, built against different runtimes:

yamlsnippet
SharedLayerNode18:
  Type: AWS::Serverless::LayerVersion
  Properties:
    LayerName: !Sub "shared-node18-${Environment}"
    ContentUri: layers/shared/
    CompatibleRuntimes: [nodejs18.x]
  Metadata:
    BuildMethod: nodejs18.x

SharedLayerNode22:
  Type: AWS::Serverless::LayerVersion
  Properties:
    LayerName: !Sub "shared-node22-${Environment}"
    ContentUri: layers/shared/     # same source
    CompatibleRuntimes: [nodejs22.x]
  Metadata:
    BuildMethod: nodejs22.x

One source of truth, two build targets. Each service is wired to one or the other.

That turns a runtime migration from a coordinated flag day into a per-service decision. A service migrates by changing which layer it references — one line — and if something breaks, the revert is the same line. No branch, no cutover window, no all-or-nothing.

The side benefit is that your parameter blocks become a migration ledger. You can read one file and see exactly which services have moved and which haven't. Progress lives in a diffable file rather than a spreadsheet somebody maintains by hand.

One thing to get right: CompatibleRuntimes and RetentionPolicy are properties of the layer resource. It's easy to indent them one level out — as siblings of Properties rather than children — and CloudFormation will happily deploy a layer with no runtime constraint and no retention policy. Then a service attaches an incompatible layer and nothing stops it. Worth a check in the fitness function you're already writing.

What actually belongs in it

The useful question isn't "what code is shared?" It's:

What am I willing to deploy to every function at once?

That reframing does most of the work.

Pure, stable, universally useful utilities — response shaping, formatting, validation — qualify easily. The cost of everyone getting a new version simultaneously is close to zero, and the code is small.

Database drivers, protocol stacks, SDK clients, anything with a connection lifecycle — generally not. They're heavy, needed by a minority of functions, and a change to one can break things in ways that are hard to predict. When a single shared manifest ends up carrying an Oracle driver, a Postgres driver, a SOAP stack and three XML parsers, every function pays for all of it: cold-start download and unpack, a vulnerability inventory that's the union of everything anyone needed, and a blast radius equal to the whole estate.

The honest counterweight, which matters more than people admit: one shared layer means one place to look. A developer wondering where a utility lives has one answer. There's no matrix of which service has which internal version. For a team that isn't going to staff the maintenance of several independently versioned internal packages, that simplicity is worth real money — and I've seen more teams hurt by premature package fragmentation than by a layer that got a bit fat.

So the move isn't "split the layer into ten layers." It's: start a new, small, deliberately scoped one, and stop the old one growing. New code targets the new layer. The old one is frozen and shrinks by attrition. Same principle as the boundary in any migration — contain the old thing rather than gradually improving it in place.

The reframe worth keeping

The layer isn't the shared code. The layer is the release channel for the shared code.

Once you see it that way, everything else follows without argument. A version bump goes through environments, because it's a deployment. It gets a changelog, because consumers need to know what changed. Somebody owns it, because deployed components have owners. Breaking a function signature is a coordinated rollout, not a refactor.

If your process for changing shared code is lighter than your process for changing a service, you've mislabelled the shared code — and the estate will eventually tell you so, all at once.

newer The Deploy-Time Bug You Can Catch at Pull-Request Time older The Import Path That Only Exists at Runtime