TL;DR — I needed email + social login. The off-the-shelf integration was a dead end, and the “proper” fix was a framework rewrite that would’ve touched every other integration we had — for one feature, on a live app. So instead of upgrading the framework to reach the library, I wrapped the library in the interface the framework already spoke. Two files, no rewrite. That seam has a name — the Anti-Corruption Layer — and knowing when to reach for it is a real engineering skill.
We wanted to add email and social login. The reason was pure growth: our app authenticated people through crypto wallets, and a wallet is a wall — no extension, no seed phrase, no sign-up. A tool called Magic knocks that wall down: it mints an embedded wallet from an email link or a Google login, so a normal person can just… sign in. Wiring it up was supposed to be an afternoon. It turned into a decision about how far you’re willing to move your whole app to adopt one dependency.
Here’s the fork I hit, why I took the road I did, and the honest tradeoff of taking it — because this isn’t a web3 story underneath. It’s a story about what you do when the feature you need lives one major version away.
The off-the-shelf option was a dead end
We were pinned to web3-react v6 — the auth framework where you integrate a wallet by writing a class that extends AbstractConnector and calling activate(connector). Class-based, stable, and exactly what our five existing wallet integrations were built on.
There was an official connector — @web3-react/magic-connector — and I assumed I’d install it and be done by lunch.
Then I noticed it had no Google login: email links only, and social sign-in was half of what we’d promised. Annoying, but maybe I could extend it. So I opened the package and clocked the second problem — it hadn’t been published in over a year. Effectively abandoned. Then I checked its dependencies, and there was the third, the one that settled it: it was pinned to magic-sdk v2, six majors behind the magic-sdk v8 that actually shipped the OAuth we needed (over there, social login lives in a separate @magic-ext/oauth extension).
The ready-made part existed, but it was a museum piece. Getting email + social sign-in through it was never going to happen.
The “proper” fix was a rewrite
The path the ecosystem pointed at was web3-react v8, where modern connectors lived. Except v8 wasn’t a version bump — it was a ground-up rewrite. A new base class (Connector, not AbstractConnector). A new init model (initializeConnector instead of Web3ReactProvider + activate). State moved into zustand, with per-connector hooks. None of our existing connector code would survive it.
Adopting it meant rewriting every wallet integration we had — injected, WalletConnect, Coinbase, Fortmatic — plus the provider wiring that everything hung off. For one login method. On a live product with real money moving through it. (And the rewrite had been sitting in alpha/beta for a year and a half, which is its own risk signal.)
That’s the fork, and you’ve probably stood at it too: the thing you need is one major away, and getting there means dragging your entire app across a breaking rewrite.
The third option: bring the library to the framework
There’s a move between “do the rewrite” and “do without,” and it’s the one people forget: don’t upgrade the framework to reach the library — adapt the library to fit the framework.
The whole idea in one picture:
your app ──talks to──▶ ┌ the interface your app already speaks ┐
│ ▲ │
│ │ satisfies │
│ ┌ your adapter ┐ │
│ │ calls │
│ ▼ │
└──────▶ the new library ──────────────┘
Your app keeps talking to the interface it already knows. Behind that interface, a small adapter you own translates to the new library. The new library’s model stops at the adapter — it never leaks into the rest of your code.
Concretely: web3-react v6 talks to wallets through AbstractConnector. So I hand-wrote a connector that satisfies AbstractConnector on the outside and calls magic-sdk v8 on the inside.
// Our framework speaks AbstractConnector. magic-sdk speaks its own API.
// This class is the translation between them — nothing else in the app changes.
class MagicConnector extends AbstractConnector {
async activate(): Promise<ConnectorUpdate> {
if (!this.magic) this.magic = new Magic(this.apiKey, { network: this.network });
if (!(await this.magic.user.isLoggedIn())) {
await this.magic.auth.loginWithMagicLink({ email: this.email });
}
const provider = this.magic.rpcProvider;
const account = (await provider.enable())[0];
return { provider, chainId: this.chainId, account }; // exactly the shape v6 expects
}
// getProvider / getChainId / getAccount / deactivate — the rest of the contract
}
Social login is the same adapter with a different inside: swap the email-link call for an OAuth redirect via @magic-ext/oauth, keep the identical { provider, chainId, account } on the outside. The provider, the hooks, the transaction code — none of it knew or cared. It just saw one more connector.
Not a single existing connector, provider, or authentication hook changed. The whole thing was contained to the new adapter and the one place we register connectors — the blast radius went from the entire wallet layer to two files.
This has a name — use it
If you ever have to defend this choice in a review (or an interview), don’t call it a hack. It’s a named, respected pattern: the Anti-Corruption Layer — Eric Evans coined it in Domain-Driven Design, and Microsoft documents it as a standard cloud pattern. The definition is almost exactly what I did: when you integrate with a system built on a different model, put a translation layer between the two so the other model can’t corrupt yours.
My connector is that layer. magic-sdk’s world ends at the adapter; my application only ever sees its own Connector interface. Same shape as an Adapter in the Gang-of-Four sense — the pattern that exists specifically to make an incompatible interface fit the one you depend on.
When to adapt, and when to just upgrade
Here’s the part that turns this from a clever trick into judgment: adapting instead of upgrading is not always right. It’s a call, and it has a wrong side.
Adapt when:
- The upgrade’s blast radius is large and the payoff is one narrow thing.
- It’s a live, high-stakes product where a regression is expensive.
- You can wall the new dependency off behind a small seam you control.
Upgrade (bite the bullet) when:
- You’d have to backport many things, not one — then you’re fighting the ecosystem forever, and the adapters become the debt.
- The old version is unmaintained or has open security holes — that’s debt accruing interest.
- The upgrade is inevitable and soon — pay it once now, not twice later.
And be honest about what you’re taking on when you adapt: you now own and maintain that connector — no upstream fixes flow to you — and you’ve deferred the framework upgrade. That’s real debt. The move is only smart if you’re choosing it with your eyes open, not to avoid a migration you’ll regret dodging.
The payoff — and the postscript
Email and social login shipped. New-user sign-up climbed noticeably — the barrier to starting had dropped from “install a wallet and back up a seed phrase” to “type your email.” The enabling work wasn’t a heroic feature; it was two small files that let us adopt a modern library without moving the whole app.
And a postscript that made the call look even better in hindsight: web3-react itself was archived — made read-only — in 2026. The v8 migration I skipped would have been a migration onto an abandoned rewrite. Sometimes the upgrade you skip is the bullet you dodge.
The one line
When the feature you need only comes bundled with a rewrite, you have a third option between doing the rewrite and doing without: wrap the new thing in the interface your app already speaks.
To be clear, this isn’t “never upgrade frameworks.” It’s a trade: when the feature is narrow and the migration is broad, adapting the library beats upgrading the framework — as long as you’re honest about the seam you now own. Flip either side of that — a broad need, or a cheap upgrade — and you should just do the upgrade.