DEV Community

Kynth
Kynth

Posted on • Originally published at apollo.kynth.studio

Building a Shared Capability Layer: Why Your Fifth App Shouldn't Reimplement Email

You know the moment. You're three weeks into a new app, and it needs to send a password reset email.

You already solved this. Twice. Once in the app you shipped last year, once in the side project before that. Both times you learned the same things: that SendGrid's sandbox mode silently swallows messages, that you need a suppression list before your first bounce, that the retry logic has to be idempotent because your queue will deliver twice eventually. Both times you wrote it down in a comment nobody read.

And now you're opening the SendGrid docs again, because copying the old file means copying its bugs, its 2024 API version, and a TODO: handle 429 you never got to.

That's the tax. Not the hard problems — the solved problems you keep re-solving at 70% quality because the last solution was welded to the app it was born in.

Copy-paste is a fork, and forks rot

The usual instinct is a shared utils package. That works until it doesn't, and it stops working for a specific reason: shared code that only shares interfaces still leaves every app owning its own state.

An email helper that wraps the SDK is nice. But bounce handling isn't a function — it's a table. Suppression lists, delivery status, retry counters, per-recipient backoff. If each app owns its own copy of that state, you don't have one email capability. You have five, and only the one you're currently looking at is correct.

The line I've landed on: a capability is a function plus the state it needs to be correct. If you extract the function and leave the state behind, you extracted the easy half.

What "built once" actually requires

Three properties, and skipping any one of them collapses it back into a utils package.

One deployment, not one library. The capability runs as a service the apps call. When you fix the 429 handling, every app has the fix — not "every app that bumps the version."

Tenancy at the boundary, not in the caller. Every call carries which app it's for. The capability owns partitioning. Apps can't get isolation wrong because they never touch it.

A narrow surface that hides the vendor. send(to, template, data) — not the SendGrid shape. Because you will migrate providers, and the migration should be one deploy, not five refactors.

# The seam that matters:
capability.send(tenant, to, template, data)
  → check suppression (shared table, all tenants)
  → render template (tenant-scoped assets)
  → dispatch via provider adapter
  → record delivery state, emit event

# Apps never see: the provider, the retry policy,
# the suppression table, the backoff curve.
# They see: send(). That's the whole API.
Enter fullscreen mode Exit fullscreen mode

The suppression check being shared is the part people push back on. If a recipient hard-bounces in App A, should App B still email them? Usually yes — different products, different consent. So the table is shared but tenant-partitioned, and the reputation signal is global. That distinction has to be a deliberate design decision, not an accident of where you put the WHERE clause.

The tricky part: versioning without freezing

Here's what bit us. One deployment means one behavior — great, until App A needs the behavior to change and App B depends on the old one.

The fix that held: capabilities may add, never change. New optional parameters, new fields on responses, new event types. Never a changed default, never a removed field. When a change is genuinely breaking, it's a new capability with a new name, and the old one keeps running until the last caller is off it.

That sounds expensive. It's cheaper than the alternative, which is every app pinning a version and quietly reconstituting the five-forks problem in a worse form.

Start with the boring ones

The instinct is to extract the interesting stuff first. Wrong order. Extract the capabilities where correctness is subtle and the surface is stable: sending, payments, auth, storage. Nobody argues about what send() should do. Everybody argues about what your domain layer should do — leave that in the apps.

The test: if you can't name the capability in one verb, it's not a capability yet.


We built this as Kynth Apollo — one capability layer, and every app we ship inherits it.

Top comments (0)