~/posts/clean-mutations-messy-clients

When clean mutations make messy clients

I came across this while completing an onboarding workflow:

I clicked “save” and the frontend fired createProfile, setPreferences, addPaymentMethod, and completeOnboarding. Four requests, in sequence, each one waiting on the last. The client wasn’t just submitting a form, it was coordinating a workflow.

I understand how this happens: behavior-driven requests feel sharper when specific. A mutation called “addAttributes” is better than a vague mutation called “updateThing”, and a mutation called “bookmarkCreate” says what it does. The problem starts when that granularity leaks straight through to the client. Suddenly the app has to map a handful of backend operations back to what the user actually meant. They’re just trying to finish onboarding, not “create a profile”, “set preferences”, and then “ask for a confirmation email”.

When backend schema makes the client break that into steps, the client now owns ordering, retries, partial failure handling, and cache complexity across a workflow.

Here’s some code that demonstrates what I mean:

async function onSave(input: FormValues) {
let profile: Profile;
try {
profile = await createProfile.mutateAsync(input.profile);
} catch (err) {
toast.error('Failed to create profile');
return;
}
try {
await setPreferences.mutateAsync({
profileId: profile.id,
...input.preferences,
});
} catch (err) {
// profile already created — what now?
toast.error('Preferences failed, but your profile was saved');
return;
}
if (input.paymentMethod) {
try {
await addPaymentMethod.mutateAsync({
profileId: profile.id,
...input.paymentMethod,
});
} catch (err) {
// profile + preferences committed, payment didn't
toast.error('Payment failed — you can add it later in settings');
return;
}
}
}

The ordering dependency is obvious, but the code comments tell the real story.

Each catch block is a product decision the API forced onto the client. Worse, when one screen starts orchestrating like this, the same sequencing logic starts to show up in the mobile client, the admin app, and then some background job. The workflow gets copied into every consumer instead of living once at the API where it belongs.

Good backend boundary, wrong edge

I don’t think the answer is to shift back to giant CRUD mutations. The useful part of behavior-oriented GraphQL is that actions are well-named and owned. An action like tagging or attaching metadata can cut across resource types in a way that is cleaner than stuffing another optional field into every CRUD resource update.

The reasoning I struggle with is the jump from “this is a nice backend boundary” to “therefore this is a good consumer-facing boundary”. Sometimes it is, but the friction starts when a single user intent spans several backend behaviors. At that point a behavior-driven schema can still be the right internal shape, but it stops being the right edge shape. The client doesn’t care that one team owns preferences and another team owns payments. The client cares that Save either produced a coherent new state or returned a result that explains what to do next.

If it’s just one user intent and domain action, then domain-shaped mutation is fine. If one user intent across several backend behaviors, that orchestration belongs at the schema edge.

The cache pays for it

The cache tells the same story. A coarse mutation that matches the screen returns enough data for the client to swap in the new state and move on. In contrast, granular mutations return fragments of the intent, which means the client has to glue the whole story together locally.

Granular mutation with cache gluing
const setPreferences = useMutation({
mutationFn: (input: SetPreferencesInput) =>
api.setPreferences(input),
onSuccess: (result) => {
// patch the profile's preferences in the cache
queryClient.setQueryData(
['profile', result.profileId],
(old: Profile) => ({
...old,
preferences: result.preferences,
})
);
// separately patch the derived onboarding status
queryClient.setQueryData(['onboarding-status'], {
profileComplete: true,
paymentMethodComplete: false,
preferencesComplete: true,
});
},
});

None of this code is hard by itself. That’s what makes it sneaky. Every mutation adds one more tiny cache patch, one more derived status update, and one more place where the UI can go stale if a field gets renamed or the screen starts depending on another piece of state. Split onboarding across three mutations and you triple the surface area for things to quietly go stale.

Compare that with a single mutation that returns the full state in one shot. The client drops in the new result and moves on, no patching, no stitching fragments back together.

Screen-level mutation with cache replacement
const completeOnboarding = useMutation({
mutationFn: (input: CompleteOnboardingInput) =>
api.completeOnboarding(input),
onSuccess: (result) => {
queryClient.setQueryData(['onboarding-screen'], result);
},
});

Cache normalization gets blamed for this a lot, but I think that’s backward. The cache code is exposing the mismatch between backend-sized steps and user-sized state. Some layer has to do the stitching. If you don’t do it at the schema edge, the cache pays for it.

Make result shapes predictable

You can’t always collapse a workflow into one happy payload. Partial failures are a real thing. But if you push that complexity to the client as a mixed bag of fields and ad-hoc errors, the branching required to handle it explodes fast.

Adding Union types can help with this. Instead of the client reverse-engineering state from a grab bag of fields, it handles a small number of explicit outcomes.

Typed result contract
enum OnboardingNextAction {
ADD_PAYMENT_METHOD
CONTACT_SUPPORT
}
type OnboardingSuccess {
profile: Profile!
viewer: Viewer!
}
type OnboardingValidationError {
fieldErrors: [FieldError!]!
}
type OnboardingPartialFailure {
profile: Profile!
completedSteps: [OnboardingStep!]!
nextAction: OnboardingNextAction!
}
union OnboardingResult =
| OnboardingSuccess
| OnboardingValidationError
| OnboardingPartialFailure

That contract changes the UI code in a useful way. You branch on a known set of outcomes instead of inferring state from whichever mutations happened to finish before an exception.

The server side of this is straightforward too. Expose one operation at the edge and let it call the lower-level domain services behind the scenes:

Schema-edge resolver
async completeOnboarding(_, { input }, ctx) {
const profile = await ctx.profileApi.createProfile(input.profile);
await ctx.preferencesApi.setPreferences({
profileId: profile.id,
...input.preferences,
});
if (input.paymentMethod) {
await ctx.billingApi.addPaymentMethod({
profileId: profile.id,
...input.paymentMethod,
});
}
// error handling returns OnboardingValidationError
// or OnboardingPartialFailure (see typed contract above)
return {
__typename: 'OnboardingSuccess',
profile,
viewer: await ctx.viewerApi.getViewer(ctx.userId),
};
}

Real code needs more than that of course. Stuff like idempotency, handling side effects, maybe a partial-failure result instead of throwing. But all of that is easier to own in one server layer than in every client. The server can see the whole workflow anyway, so its easier to log it, test it, and evolve it without making three apps relearn the same failure matrix.

None of this means backend teams should stop designing behavior-oriented capabilities. Those boundaries can still be exactly right inside your graph or behind the gateway. But if the client has to become a workflow engine to use the API, the boundary is in the wrong place.