Skip to content
Chapter 64Lesson 7

When an SDK adapter earns its weight

A three-question test for deciding when a third-party SDK like Stripe, Resend, or R2 deserves a wrapping interface, a thin helper, or a direct call.

By now you’ve wrapped two of the third-party libraries this app depends on. Billing got an interface in the last lesson, so app code calls billing.upgrade, never stripe. Authorization got one earlier: every privileged action goes through authedAction, never the raw auth check. You’ve also left at least one library un-wrapped: when the app sends a welcome email it calls sendEmail, a thin convenience function that reaches straight for Resend.

Now picture a teammate opening a pull request. They’ve wrapped Resend the same way you wrapped Stripe: a lib/email/ directory, a clean little interface, Resend forbidden everywhere else. The diff is tidy. The justification in the description is one word, consistency, and every reviewer’s instinct is to approve it, because it looks like the billing code you already blessed.

Is it right?

That question is the whole lesson, and “it’s tidier” is not an answer to it. Every wrapper you add is a module someone has to maintain and an extra hop a reader has to follow to find out what actually happens. Those are real costs, paid for as long as the code lives, and “for consistency” doesn’t pay them back. So you need something firmer than taste here: a test you can run on any library that returns a yes or a no along with a reason you can write in a review comment. That is what this lesson gives you. By the end you’ll be able to look at any new SDK and decide, by following a fixed procedure, whether it deserves a wrapper. You’ll also see why the silence around Resend, and around two libraries you’ll meet later, is a deliberate decision rather than an oversight.

The three questions a wrapper has to answer

Section titled “The three questions a wrapper has to answer”

A wrapper earns its keep when it answers yes to three questions, asked in a fixed order. The order matters: each later question only counts once the earlier ones have passed, and jumping straight to “but it would be consistent” is exactly the move this order is built to prevent.

Throughout, measure each question against the one yes-case you already own: billing. You built it last lesson, so it’s the most reliable yardstick you have.

One: is the SDK’s shape hard to read at the call site? Does calling the library directly bury the intent under structural noise? Here is what starting a Pro upgrade looks like in raw Stripe:

const session = await stripe.checkout.sessions.create({
mode: 'subscription',
customer: org.stripeCustomerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${appUrl}/billing/success`,
cancel_url: `${appUrl}/billing`,
subscription_data: { metadata: { organizationId: org.id } },
});

That is six lines of structure to say one thing: upgrade this org to Pro. A reader skimming the file has to reconstruct the intent from the shape. The call site is hard to read, so billing answers yes. Compare that against Resend, which you’ll judge properly in a moment:

await resend.emails.send({ from, to, subject, react });

That already reads as its own intent; nothing is buried. So on the first question, Resend answers no, and a no this early is a strong signal that the library doesn’t want a wrapper.

Two: is the swap cost real? Across the realistic changes this code will live through, such as a pricing experiment or a vendor migration, how many call sites have to be rewritten when something changes? Swap cost is not “could you theoretically swap vendors,” since almost anything is theoretically swappable. It is “how many places actually move when it changes.” Billing call sites get rewritten every time the pricing team runs an experiment, and a vendor migration would touch that session-creation shape in every file that creates one. That’s a high swap cost, and the wrapper means the churn lands in one module instead of fifty.

Three: must a discipline be centralized? Is there a rule that has to hold at every call, one that would otherwise be re-implemented slightly differently each time, or forgotten outright? Billing has several: every upgrade must run inside an org, must resolve to a Stripe Customer, and every paid surface must pass requirePlan before it renders. That last one is the real prize. Without it, you’d have to ask of every privileged route by hand, “did someone remember to check the plan here?” requirePlan turns that into a single function nobody can route around. Billing answers yes.

Three yeses. Now the cut, stated as a rule you can apply without re-deciding it each time:

This gives you a clear answer every time. You don’t argue about it; you answer three questions and read off the verdict.

The interactive below walks those questions in the order an experienced engineer asks them, shape first, then swap cost, then discipline, and lands on one of the three verdicts. What matters is not any single ending but the order. Once these three questions are second nature in this sequence, the answer falls out on its own. Try walking it a few times with different libraries in mind.

Does this SDK earn a wrapper?

The walk above never lands on “call it directly.” That verdict sits below the questions, as the floor you fall to when an SDK is already terse and is its own discipline-bearing layer. We’ll meet a library like that shortly.

Helper or interface: the line that actually matters

Section titled “Helper or interface: the line that actually matters”

Two of the three verdicts produce code that wraps an SDK: a helper and an interface. These two get confused constantly, and that confusion is the most expensive mistake in this whole topic, so slow down here.

The difference is not “small function versus big module.” A helper can be elaborate, and an interface can be three lines. The difference is about enforcement: whether the rest of your code is still allowed to touch the SDK directly.

A helper is a function that simplifies one call. The underlying SDK stays importable everywhere. sendEmail runs a suppression check and then calls resend.emails.send directly inside its own body, but nothing stops another file from importing resend and calling it too. The helper and the direct call coexist. The helper is a convenience, not a border.

An interface is a module with a stable public surface where the SDK is forbidden outside one directory. App code imports billing.upgrade; it never imports stripe. Stripe becomes a transitive dependency : present in your node_modules, reachable only through the seam. That restriction is the whole point, because some disciplines only hold when there’s no second door. “Every touch of this SDK must be auditable” and “the secret-bearing client must live in exactly one place” both collapse the moment a second file can reach the SDK on its own.

So the question that separates the two is sharp, and it’s the one to carry out of this section:

Is the rest of the code forbidden from touching the SDK?

For Stripe and the auth library, yes: audit, secret handling, and gating all break the moment a second file reaches around the seam. For Resend, no: a stray direct resend.emails.send is fine, because the helper was never a wall. That single yes-or-no is the entire helper-versus-interface decision.

Here are both side by side, kept to a signature and a single call, because the full implementations shipped last lesson and ship in the project. Watch where the SDK import lives, and ask the forbidden question of each.

lib/email.ts
export const sendEmail = async (input: SendEmailInput) => {
if (await isSuppressed(input.to)) return;
return resend.emails.send(input);
};

The SDK lives inside the function, and stays importable everywhere else. sendEmail adds one pre-flight step, a suppression check, then calls resend directly. Another file is free to import { resend } and call it too, so the two coexist. This is a convenience, not a boundary.

The two code blocks tell you what differs; the next figure shows what that difference looks like as a shape in your codebase, which is what finally makes it stick. On the left is the wrapped case: app code, the billing.* seam, and the Stripe SDK buried beneath it, reachable through nothing but the seam. On the right is the un-wrapped case: app code with two legal paths to Resend, one through the sendEmail helper and one straight to the SDK. Read the left panel’s seam as a wall, and the right panel’s helper as a shortcut that sits next to a door that’s still open.

A helper simplifies a call; an interface forbids the call from happening anywhere else.

That image, a wall on the left and an open door on the right, is the central picture of the whole lesson. Everything that follows is just running the test and reading off which side a given library lands on.

The interface verdict has a formal name, the anti-corruption layer, and seeing it framed independently of this app will make the pattern stick.

Running the test on the course’s five integrations

Section titled “Running the test on the course’s five integrations”

You’ll touch five third-party integrations across this course. Two you’ve already wrapped; three you have not, or will not. Run the three questions on each. Start with the three un-wrapped cases, because they’re the surprising ones, the places where the “wrap it for consistency” instinct is loudest and most wrong.

Resend, left un-wrapped on purpose. Shape: terse. send({ from, to, subject, react }) already reads as the intent → no. Swap cost: low. Every transactional-email vendor exposes the same handful of fields, so a migration barely touches the call → low. Discipline: there is a pre-flight rule (don’t send to a suppressed address), but it lives happily inside the sendEmail helper and doesn’t require forbidding Resend everywhere. Verdict: helper. You built sendEmail back in the email work; that’s the artifact, and it’s correctly a helper, not an interface.

Trigger.dev, left un-wrapped on purpose. This is a background-job library you’ll meet properly in a later chapter; here you’re only reading its shape. Kicking off a durable task looks like this:

await myTask.trigger(payload);

That’s already terse, and here’s what puts it on the floor: the SDK’s own primitives are the discipline-bearing layer. The input schema and the idempotency key live in the task definition, enforced by the library itself, not in some outer wrapper you’d write. Wrapping trigger would add a hop and centralize nothing the library hasn’t already centralized. Verdict: direct call. (You’ll learn how task and its schema actually work later; for now, just read the shape.)

R2, left un-wrapped on purpose. Cloudflare R2 is the object storage you’ll meet in a later chapter; again, shape only. Uploading a file looks like this:

await s3.send(new PutObjectCommand({ Bucket, Key, Body, ContentType }));

This one is more interesting, because it partly passes. The shape is genuinely verbose → partly hard to read. And the swap cost is real, since R2, S3, Backblaze, and Tigris are interchangeable behind the same protocol → medium. So the first two questions push toward a wrapper. The third doesn’t, and the call sites don’t multiply: there are only two in practice (one presigned upload, one presigned download), and no cross-cutting rule has to hold across them. So a presignedPut(key) helper wraps the verbosity at each site, with the raw SDK call one line below: two yeses, no central discipline. Verdict: helper.

The two un-surprising cases close the matrix:

Authorization passes three-for-three. The authz check is structural noise at the call site → yes. An auth-provider change would touch every action → high swap cost. And the rule “this action must be authorized” has to hold at every privileged action → central discipline. authedAction is the first of the course’s two interfaces.

Billing passes three-for-three. This is the anchor from the start of this lesson, and the second interface.

Here is the whole thing as one table. This is the reference artifact, the answer to “why is this wrapped and that isn’t” in one glance.

| Test | Resend | Trigger.dev | R2 | Authorization | Billing | |------|--------|-------------|----|---------------|---------| | Shape hard to read | no | no | partly | yes | yes | | Swap cost real | low | medium | medium | high | high | | Discipline lives in wrapper | helper does it | SDK already does | helper does it | yes (authz) | yes (gating, scoping) | | Verdict | helper | direct call | helper | interface | interface |

Read the table by counting yeses. Three-of-three is an interface, and only billing and authorization clear it. R2 collects two (verbosity plus a real swap cost) but no central discipline, so it’s a helper. Resend and Trigger.dev sit lower still. The thing to remember: across five integrations, the wrapper rule is applied twice and withheld three times.

Now run it yourself. Below are the five integrations plus a couple of libraries you haven’t been handed an answer for, because the skill isn’t reciting this table, it’s running the three questions on something new. Sort each one by how app code should reach it.

Sort each integration by how app code should reach it — run the three questions to a verdict. Drag each item into the bucket it belongs to, then press Check.

Interface in /lib SDK forbidden outside one directory
Helper at the call site SDK still importable everywhere
Call the SDK directly Already terse, no rule to centralize
Stripe billing — checkout, portal, and plan-gating (billing.*)
The authorization wrapper around every privileged action
A feature-flag SDK that must be checked at every gated route, always with the same fallback rule
Transactional email send with a suppression check
Two presigned-URL calls to object storage
A durable-task SDK whose primitives already carry the input schema and idempotency key
An SMS vendor with a one-line send({ to, body }), used in three places, with no cross-cutting rule

Why “wrap it for consistency” is the wrong instinct

Section titled “Why “wrap it for consistency” is the wrong instinct”

Now you can answer the teammate’s pull request precisely.

The request to wrap Resend “to match billing” is aesthetic. It optimizes for a codebase that looks uniform, with every integration behind its own neat little module. Uniformity feels like quality, but it isn’t. Here’s what that wrapper actually costs, paid not once but for as long as the code lives:

  • It’s a maintenance surface. It’s a module that has to be kept in sync with the SDK it hides, so every time Resend changes a field, your wrapper is one more thing to update.
  • It’s an indirection. Every call that routes through the wrapper is one more hop a reader has to follow to learn what really happens. You traded a direct, legible call for a layer that hides it.

In exchange for those costs, the Resend wrapper centralizes nothing: the suppression check already lives in sendEmail, and there’s no rule that needs Resend forbidden elsewhere. You’d be paying the full price of an interface to buy symmetry, and symmetry is not something your users, your on-call rotation, or your future self will ever thank you for. The bar is decision quality, not uniformity. A layer earns its place only by paying back the indirection it adds, and most don’t.

So here is the reframe to take into your next review. The absence of a wrapper around Resend, Trigger.dev, and R2 is a considered, defensible decision, not an oversight. When a reviewer asks “why isn’t this wrapped like billing?”, the answer is not a shrug and it is not “we’ll get to it.” The answer is the three questions, run out loud, landing on a verdict. “For consistency” never appears in that reasoning, because it was never a reason.

To make that concrete, here’s the pull request itself. Review it the way you’d review a teammate’s. Click the line where the real problem lives and leave the comment you’d actually write.

A teammate opened this PR to 'wrap Resend like billing, for consistency.' Review it. Click any line to leave a review comment, then press Submit review.

src/lib/email/index.ts
import 'server-only';
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
export const email = {
send: (input: SendEmailInput) => resend.emails.send(input),
};

Withholding the wrapper four times out of five is the easy half of the discipline. The harder half is being honest about what the two interfaces you did build actually buy you, because if the answer were “nothing,” you’d tear them out too. They aren’t free: they cost a maintenance surface and a hop, the same as the Resend wrapper would have. The difference is the return. Here’s what centralizing the discipline pays back, and every item traces to one fact: the seam exists, so there’s one place for things to attach.

A single test seam. Because every billing operation routes through billing.*, an integration test mocks three methods, not the whole Stripe SDK scattered across a dozen files. The seam is the natural mock point: the test substitutes your interface and never has to know Stripe was underneath. An un-wrapped SDK gets mocked at each helper or call site instead, which is fine when there are few of them and painful when there are many. (You’ll build these tests properly when the course reaches testing; here it’s enough to see why the seam makes them tractable.)

Version pinning in one place. Stripe’s client takes an apiVersion, and this chapter pinned it to 2025-03-31.basil. Because the client is constructed exactly once inside /lib/billing/, that version lives in a single file. Bumping it is one diff in one place you can test in isolation. Scatter new Stripe(...) across the app and the API version becomes a global concern you have to chase down everywhere.

A natural home for observability. The seam is where logs and metrics want to live. billing.upgrade is the obvious place to log the org, the plan, and the session id; a requirePlan failure is the obvious place to bump a counter. Wrappers attract instrumentation, and that’s useful rather than accidental. Put the logging where every call already passes and you cover every call for free. An un-wrapped SDK forces you to remember the log line at each site.

The class, hidden behind functions. Stripe’s Node SDK is a class: new Stripe(secret) gives you an object whose methods you call. Early in the course, when you learned where classes still earn their weight, one of the few cases was an adapter wrapping a class-based SDK. That’s exactly what billing.* is: the class instance is constructed and held inside lib/billing/stripe.ts, and the public surface the app sees is plain functions. “Wrap the class behind functions” and “give this SDK an interface” are the same decision, seen from the object-oriented side. This is what an adapter is: a boundary that keeps the vendor’s shapes from leaking into the rest of your code.

That’s the dividend, and it’s the same on both interfaces: a test seam, a single pinning point, a home for observability, a class kept behind functions. Four payoffs, each a direct consequence of the seam existing, none of which the three un-wrapped libraries needed, which is exactly why they didn’t get one.

So here is the chapter’s whole posture toward third-party SDKs, stated once, to carry into every future integration you add:

You’ve named the principle once, applied it twice, and withheld it three times. The next time someone opens a PR to wrap a library “for consistency,” you won’t reach for taste; you’ll reach for the three questions, and the answer will already be written.

The wrapped-vs-direct decision is one face of a long-standing pattern with names worth knowing. The first link is the canonical framing of an anti-corruption layer , a boundary that stops a vendor’s model from bleeding into yours, complete with the latency and maintenance trade-offs this lesson weighed. The second names the object-oriented sibling, the adapter pattern, with an interactive illustration. The third is a balanced, case-by-case take on when wrapping a third-party library earns its keep and when it just adds a layer.