Most tutorials assume your frontend talks to exactly one clean, consistent API. Real products rarely give you that. You end up with two services that grew up separately — say an e-commerce API and a lab/diagnostics API — with different response shapes, different auth conventions & different ideas about how a file upload should work. And you have one interface that has to sit on top of both as if they were the same thing.
The failure mode here is subtle & I've walked straight into it. You wire the first backend into your components directly. Then the second one shows up, its responses are shaped differently, so you reach for the quickest fix: a conditional. Just one. Then another. Six months later, if (service === "lab") is scattered through thirty components, each one carrying a little knowledge about which backend it's really talking to. The UI has absorbed the shape of your infrastructure & now you can't change the infrastructure without touching the UI.
The boundary that fixes it
The fix is a single idea: components should ask for what they want & one layer below them should decide which backend answers and how. Components never name a service. They never branch on one. They call a data layer that presents one clean surface & everything ugly lives behind that surface.
// Components call this. They never know which backend answers.
export const data = {
orders: makeResource("order"),
labTests: makeResource("labTest"),
};
// The resource decides routing, auth & serialization internally.
function makeResource(name: string) {
const backend = routeFor(name); // e-commerce vs lab, decided here
return {
list: (q) => backend.get(pathFor(name), q).then(deserialize(name)),
get: (id) =>
backend.get(`${pathFor(name)}/${id}`).then(deserialize(name)),
create: (body) => backend.post(pathFor(name), serialize(name, body)),
};
}The component that renders a list of orders imports data.orders and calls .list(). It has no idea there are two backends in the building. That ignorance is the entire point — it's what lets the two services move independently underneath.
Serialization is the real seam
Routing is the easy half. The half that actually earns its keep is serialization: turning two different response shapes into one internal model your components can trust.
If one backend returns created_at and the other returns createdAt & one nests the customer under customer.data.attributes while the other returns it flat, you do not want that difference to reach a component. You normalize both into a single shape at the boundary, on the way in & you serialize back to each backend's expected format on the way out.
// Two backends, one internal model. Components only ever see Order.
const deserializers = {
order: (raw) => ({
id: raw.id,
code: raw.order_code,
createdAt: raw.created_at,
customer: raw.customer, // already flat here
}),
labTest: (raw) => ({
id: raw.id,
code: raw.test_ref,
createdAt: raw.attributes.createdAt, // nested here
customer: raw.attributes.patient,
}),
};Once this seam exists, your components are written against Order and LabTest — clean internal types — not against whatever JSON two different teams happened to design. The messiness stops at the door.
The parts that don't fit the happy path
Uploads were where this earned its keep for me. The two backends wanted files handed to them differently — different field names, different multipart conventions, one expecting a pre-signed step the other didn't have. If that leaks, every form in the app has to know which upload dance to perform. Folding it into the data layer means a component just calls upload(file) and the layer performs whichever ritual that resource requires.
The same goes for pagination that's cursor-based on one side and page-numbered on the other, or auth tokens that live in different headers. Every one of these is a small decision & every one of them wants to live in exactly one place. The data layer is that place.
What it buys you later
The reason to pay this cost up front is that it changes what a future migration looks like. When one backend gets replaced, merged, or versioned — and in a live product, one eventually will — the change is a data-layer change. You update routeFor and the deserializer, run your tests & the UI never finds out. Compare that to the alternative: hunting down thirty if (service === ...) branches across the component tree and praying you found them all.
That's the whole trade. You accept one layer of indirection now to avoid a full-app rewrite later.
When you shouldn't do this
I want to be honest about the cost, because indirection is not free and cargo-culting it is its own mistake. If you have one backend and no realistic plan for a second, this pattern is pure overhead — a boundary protecting you from a problem you don't have. Write against your one API directly and get on with it.
The signal to build the boundary is specific: you have two or more genuinely different backends behind one UI, or you're mid-migration from one to another and both are live at once. That's when a component branching on service names stops being a small convenience and starts being the thing that quietly welds your interface to your infrastructure. Draw the boundary before that weld sets & the UI stays yours to change.