Class 9: Case Study: Shopify
Duration: 15 minutes | Difficulty: Intermediate | Prerequisites: Class 5 of this course.
Everything here is drawn from Shopify's public developer documentation as of September 2026; where this lesson makes a claim about Shopify's API, the link beside it is the source.
What You'll Learn
By the end of this class, you will:
- Read the
userErrorspattern at its source, the Shopify Admin API - Trace its evolution from a generic two-field type to per-mutation typed errors with closed code enums
- See how the
DisplayableErrorinterface gives generic clients a stable contract while typed clients get specifics - Judge which parts of the pattern are portable design and which are consequences of Shopify's particular position
Why Shopify is the canonical case
Class 5 presented the design ladder in the abstract; Shopify's Admin API is the deployment through which one of its rungs became widely known. The Admin API is the programmable surface of the Shopify platform. It serves the apps in the Shopify App Store, whose front page counts more than 16,000 of them (Shopify's main site puts the figure at over 21,000), many written by third-party developers outside Shopify. That audience shapes everything about the design: errors must be discoverable from the schema alone, mechanically renderable in form UIs the app authors build, and stable across years of API versions. When the ecosystem says "the Shopify pattern", it means the shape this class examines. When our own schemas borrow it, as Class 5's CreateReviewResponse did, we are borrowing decisions made for this audience. The external-convention label this site attaches to the ...Payload naming, while borrowing the userErrors shape, is a reminder to re-check those decisions against your own audience.
The split: two channels, two audiences
Shopify's API draws the line Class 5 drew, as its Admin API reference and mutation pages document. The top-level errors mechanism carries what Class 4 called failures: malformed requests, authentication problems, throttling, and system-side conditions such as INTERNAL_SERVER_ERROR. The same reference is direct about status codes:
Most importantly, the GraphQL API can return a
200 OKresponse code in cases that would typically produce 4xx or 5xx errors in REST.
Class 3 explained the transport reasoning.
Shopify also uses extensions for rate-limit data. Responses carry a cost object holding requestedQueryCost, actualQueryCost and a throttleStatus block of maximumAvailable, currentlyAvailable and restoreRate, per the Admin API rate limits documentation, so clients can pace themselves against rate limits from response metadata instead of status codes.
Business outcomes, meanwhile, never touch the errors list. Every mutation payload in the Admin API carries a userErrors field, and the productCreate mutation shows the base form: alongside product ("the product object"), the payload has userErrors: [UserError!]!, "the list of errors that occurred from executing the mutation". The field is non-null and the list is always present: success is an empty list, and the outcome channel is part of the mutation's response type. The UserError type itself has exactly two fields: field: [String!], "the path to the input field that caused the error", and message: String!, "the error message".
The field path deserves the attention Class 5 gave it, because form UIs depend on it most. The productVariantsBulkCreate reference shows a rejected call answered with "field": ["productId"] and the message "Product does not exist". That mutation's userErrors list already uses a typed ProductVariantsBulkCreateUserError, one of the newer shapes covered in the next section, and the field path works the same way on both generations. The path is a machine-readable pointer into the input the app just sent, which the app maps to the exact form control that needs an error message. Because the pointer is machine-readable, an app can render validation feedback for every mutation with one generic form component, without parsing message strings. That is the practical benefit of designing errors for external developers whom the API team cannot advise one by one.
The evolution: from strings to typed codes
What simplified retellings miss is that Shopify's pattern did not stop at the two-field type, and the API's own reference pages record the evolution. The base UserError carries only a path and a message, which leaves clients branching on message strings, the practice Class 4 warned against. Shopify's newer mutations fix this with per-mutation error types, and the metafieldsSet mutation's error type is representative. MetafieldsSetUserError carries field and message like its ancestor, plus code, typed as the MetafieldsSetUserErrorCode enum: a closed, introspectable list of exactly what can go wrong setting metafields. It also carries context fields the specific operation needs, such as elementIndex, "the index of the array element that's causing the error", for batched inputs.
Holding the two generations together is an interface, precisely the move Class 5's ladder predicted. Per its reference page, the Admin API defines:
interface DisplayableError {
field: [String!]
message: String!
}
Typed error types like MetafieldsSetUserError implement DisplayableError, and so does the base UserError. A generic form component selects the interface fields and works against every mutation in the API, old or new; a client that cares which metafield failure occurred selects code and branches on the enum. This is Giroux's interface stage deployed across the whole Admin API, and it shows the migration property that makes the design durable. Shopify could introduce typed errors mutation by mutation, over years and quarterly API versions, while every client that handled the generic contract kept working.
Two details distinguish Shopify's rung on the ladder from Class 5's final one. The mutations examined here list one concrete error type per mutation in userErrors, where Class 5's final shape uses a union of outcome types. Introspecting such a mutation's response therefore shows a single error type. The closed enumeration lives one level down, in the code enum. The entity field and the error list also share one response, with product nullable and userErrors non-null. That is the impossible-state trade-off Class 5 discussed, which Shopify accepts in exchange for uniformity across the whole surface.
What generalizes, and what is Shopify-shaped
Portable, and worth copying.
- The audience split: system failures in one channel for infrastructure and generic handling, business outcomes in the schema where introspection and codegen see them.
- The machine-readable
fieldpath, which any API serving form-driven UIs should treat as mandatory. - The closed per-operation code enum, which is Class 4's "small and closed vocabulary" rule applied at mutation granularity.
- The interface underneath typed errors, which enables generic handling plus gradual typing, the exact combination that let the pattern evolve in place for years.
Shopify-shaped, and worth re-deciding.
- The unconditional
userErrorson every payload is a policy for an API with thousands of third-party integrators: uniformity matters more than elegance here, because the pattern must be learnable once and applicable everywhere. An internal API with a handful of known clients can afford per-mutation judgment, or the union-based shape Class 5 recommends for its stronger typing. - Concrete error types in place of unions. Shopify has not published why it chose concrete error types over unions, and a new API can combine Shopify's interface-and-codes discipline with union payloads.
- Failure that looks like success. This cost transfers to every adopter of the pattern. A mutation that fails its business validation still returns
200 OKwith a well-formed response, so a client that skipsuserErrorssees success. Shopify's API reference says the detail of a mutation error only reaches clients that ask for it: "To access this, you must requestuserErrors." The exhaustiveness burden Class 5 named applies at Shopify's scale too, and each client has to carry it.
Summary
Shopify's Admin API is the errors-as-data pattern deployed for an audience of thousands of third-party developers:
- Two channels. System failures and rate limiting ride the top-level mechanism, with
extensions.costmetadata compensating for200 OKtransport. Every mutation response carries a non-nulluserErrorslist whose base type holds a machine-readablefieldpath into the offending input, plus a human message. - Evolution. The pattern evolved without breaking existing clients: newer mutations return per-mutation error types such as
MetafieldsSetUserError, adding closedcodeenums and operation-specific context, all implementing theDisplayableErrorinterface so generic form components keep working. - Portable lessons: the audience split, the field path, per-operation code enums, and the interface underneath.
- Shopify-shaped choices to re-decide for your own API: unconditional error fields on every payload, concrete error types in place of unions, and the accepted risk that clients who skip
userErrorsmistake failure for success.
Further Reading
- Shopify Admin API, UserError: the base type, two fields, and the description of the
fieldpath this class quoted. - Shopify Admin API, DisplayableError: the interface holding the two error generations together.
- Shopify Admin API, MetafieldsSetUserError: a representative modern typed error, with its
codeenum andelementIndexcontext field. - Shopify Admin API, productCreate: a base-form mutation payload showing the unconditional
userErrors: [UserError!]!. - Shopify Admin API rate limits: the
extensions.costandthrottleStatusmetadata that carries rate-limit signal in-band.
What's Next
Shopify applies one error-handling philosophy consistently across its whole API surface. Class 10 puts two public APIs side by side that chose differently. GitHub keeps errors top-level, with a typed type key on each error object. GitLab writes errors-as-data into its own contributor conventions. The class reads the reasoning each organization published.