Skip to content
Chapter 64Lesson 7

When to wrap a third-party SDK

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

By now you’ve wrapped two of this app’s third-party libraries. Billing got an interface last lesson, so app code calls billing.upgrade, never stripe. Authorization got one earlier: every privileged action goes through a wrapper, never the raw auth check. One library you left unwrapped: to send a welcome email, the app calls sendEmail, a thin convenience function that reaches straight for Resend.

Now a teammate opens a pull request. They’ve wrapped Resend the way you wrapped Stripe: a lib/email/ directory, a clean interface, Resend forbidden everywhere else. The diff is tidy, the justification one word, consistency. It looks like the billing code you already blessed, so every instinct says approve.

Is it right?

“It’s tidier” is not an answer: every wrapper is a module to maintain and an extra hop a reader must follow. You need something firmer than taste, a test you can run on any library that returns a yes or no with a reason you can write in a review comment. By the end of this lesson you’ll look at any new SDK and decide, by a fixed procedure, whether it earns a wrapper, and why leaving Resend alone was deliberate, not 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 only by answering yes to three questions, asked in a fixed order: each later question counts only once the earlier ones pass. Asking them in order is what stops “but it would be consistent” from deciding on its own.

Measure each against the one yes-case you already own: billing, built last lesson and so your most reliable yardstick.

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 a Pro upgrade 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 } },
});

Six lines of structure to say one thing: upgrade this org to Pro. A reader has to reconstruct that intent from the shape, so billing answers yes. Compare Resend:

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

That already reads as its own intent. Resend answers no, and a no this early signals the library doesn’t want a wrapper.

Two: is the swap cost real? Across the realistic changes this code will live through, a pricing experiment or a vendor migration, how many call sites get rewritten? Not “could you theoretically swap vendors,” since almost anything is theoretically swappable, but how many places actually move. Billing call sites get rewritten on every pricing experiment, and a vendor migration would touch the session-creation shape in every file that creates one. That’s a high swap cost, and the wrapper lands the churn 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? 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 is the real prize: without it you’d have to ask of every privileged route by hand whether someone remembered to check the plan. requirePlan makes it a single function nobody can route around. Billing answers yes.

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

The walker below applies the questions in order, shape then swap cost then discipline, and never lands on “call directly”: that verdict is 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.

Does this SDK earn a wrapper?

Two of the three verdicts wrap an SDK: a helper and an interface. They get confused constantly.

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

A helper simplifies one call, and the SDK stays importable everywhere. sendEmail runs a suppression check and then calls resend.emails.send in its own body, but nothing stops another file from importing resend and calling it too. Helper and direct call coexist; it’s 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 node_modules, reachable only through the seam. That restriction is the whole point, because some disciplines hold only when there’s no second door. Auditing every touch of the SDK, or keeping the secret-bearing client in exactly one place, both collapse the moment a second file reaches the SDK on its own.

So one sharp question separates the two, 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.

Here are both side by side, kept to a signature and a single call. 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 suppression check, then calls resend directly. Another file can import { resend } and call it too, so the two coexist. A convenience, not a boundary.

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

The interface verdict has a formal name, the anti-corruption layer .

Running the test on the course’s five integrations

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

This course touches five third-party integrations. You’ve already wrapped two; the other three you haven’t. Run the three questions on each, starting with the three un-wrapped cases, where the “wrap it for consistency” instinct is loudest and most wrong.

Resend. Shape: send({ from, to, subject, react }) already reads as the intent → no. Swap cost: 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 inside the sendEmail helper without forbidding Resend everywhere. Verdict: helper — the sendEmail you built earlier, correctly a helper and not an interface.

Trigger.dev. This background-job library gets a full treatment in a later chapter; here, read only its shape. Kicking off a durable task looks like this:

await myTask.trigger(payload);

Already terse — and what puts it on the floor is that the SDK’s own primitives are the discipline-bearing layer. The input schema and idempotency key live in the task definition, enforced by the library itself, not in some outer wrapper. Wrapping trigger would add a hop and centralize nothing the library hasn’t. Verdict: direct call.

R2. Cloudflare’s object storage, also a later chapter; shape only. Uploading a file looks like this:

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

This one partly passes. The shape is verbose → partly hard to read, and the swap cost is real, since R2, S3, Backblaze, and Tigris are interchangeable behind the same protocol → medium. The first two questions push toward a wrapper; the third doesn’t. There are only two call sites (one presigned upload, one presigned download) and no rule has to hold across them, so a presignedPut(key) helper wraps the verbosity at each site, raw SDK 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. 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. The anchor from the start of this lesson, and the second interface.

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

TestResendTrigger.devR2AuthorizationBilling
Shape hard to readnonopartlyyesyes
Swap cost reallowmediummediumhighhigh
Discipline lives in wrapperhelper does itSDK already doeshelper does ityes (authz)yes (gating, scoping)
Verdicthelperdirect callhelperinterfaceinterface

Only billing and authorization clear all three; across the five integrations, the wrapper rule is applied twice and withheld three times.

Now run it yourself on five integrations plus two libraries you haven’t been handed an answer for, sorting each 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

Now you can answer the teammate’s pull request.

Wrapping Resend “to match billing” is an aesthetic request, but uniformity isn’t quality. The wrapper would cost a maintenance surface and an indirection hop for as long as the code lives, and centralize nothing: the suppression check already lives in sendEmail, and no rule needs Resend forbidden elsewhere. The bar is decision quality, not symmetry. So the missing wrapper around Resend, Trigger.dev, and R2 is a deliberate verdict, not an oversight. When a reviewer asks “why isn’t this wrapped like billing?”, the answer is the three questions run out loud; “for consistency” was never one of them.

Here is the pull request. Review it as you would a teammate’s: click the line where the real problem lives and leave the comment you’d 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),
};

The two interfaces you did build cost the same maintenance surface and hop as the Resend wrapper would. The difference is the return, and every payoff below traces to one fact: the seam is a single 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 call site instead, fine when there are few and painful when there are many.

Version pinning in one place. Stripe’s client takes an apiVersion, pinned in this chapter to 2025-03-31.basil. Because the client is constructed exactly once inside /lib/billing/, that version lives in a single file, and bumping it is one diff you can test in isolation. Scatter new Stripe(...) across the app and the API version becomes a global concern 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. Put the logging where every call already passes and you cover every call for free.

The class, hidden behind functions. Stripe’s Node SDK is a class: new Stripe(secret) gives you an object whose methods you call. The 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 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.

None of the three un-wrapped libraries needed any of these four payoffs, which is why they didn’t get an interface. The posture to carry into every future integration:

These name the pattern behind the interface verdict and weigh the wrap-vs-direct call.