The Reluctant Guide to Shopify Migrations

Act IV: The Joinery

Get Your Hands Off The Checkout

The team had spent two sprints on it, which tells you they thought it mattered, because nobody spends two sprints on something they think is small. The brand sold gifts—the kind you send to someone else, on a date you choose, with a note—and the requirement was simple to say out loud: a customer buying a gift had to pick a delivery date, and the order could not go through without one. So the engineers built a Checkout UI Extension that rendered a date picker, and they wired it to block the order until the field had a value. block_progress, declared. The buyer could not move forward without choosing a day. They tested it the way you test things you’re proud of, which is thoroughly. QA passed. It shipped.

Within a week, orders were arriving with no delivery date at all.

Not many at first, which is the cruel part, because a trickle reads like an edge case and an edge case reads like something you’ll get to. But the trickle had a shape. The orders with no date were almost all mobile. They were almost all paid with Apple Pay. And when someone finally sat down and traced one of them all the way through, the explanation was both completely mundane and slightly horrifying: the buyers paying with Apple Pay had never seen the date picker. Not seen-it-and-skipped-it. Never rendered. The validation the team had built—the thing two sprints went into, the thing that blocked progress, the thing QA signed off on—was invisible to the wallet flow entirely. It ran in a part of the checkout that the highest-converting buyers on the site simply never visited.

Nobody had done anything wrong. That is the sentence to sit with. The requirement was reasonable. The code was correct. The tests were honest. And the feature still failed in production for exactly the customers it most needed to work for, because the team had built it on an assumption that was true on their old platform and quietly, catastrophically false on this one.


Here is the assumption, stated plainly, because naming it is most of the cure. On the platforms these teams are arriving from—Magento, Salesforce Commerce Cloud, a bespoke stack someone’s been tending for a decade—the checkout is a place where your code runs. It is yours. It is a universal extension point: a series of controllers and templates and hooks where you can compute whatever you like, call whatever you like, block whatever you like, in the live request, in front of the buyer, every time. Ten years of building on a platform like that wires a belief into a team so deeply they stop noticing it’s a belief: if I need something to happen at checkout, I make it happen at checkout. That belief was correct for a decade. It paid for itself a hundred times. And it is the single most expensive thing an enterprise team brings with them to Shopify, because on Shopify it is wrong.

On Shopify, the checkout is not a place where your code runs. It is a place where data gets shown.

That is the whole chapter, and you could stop reading here if the sentence landed hard enough, but it rarely does on the first pass, because it sounds like a limitation rather than a description, and the instinct is to treat limitations as things to work around. So let me draw the actual shape of the thing, because the shape is more useful than the slogan, and the shape is what tells you where your code is allowed to live.

Think of the buyer’s journey through your store as having three surfaces, and think of them in order. There is a before, there is a middle, and there is an after, and almost everything that goes wrong on an enterprise checkout build is logic that belongs in the before or the after being shoved into the middle.

The before is everything up to the checkout: the cart, the storefront, the product pages, and—reaching back further—the systems that feed them, the ERP and the OMS and the PIM and the pricing engine and whatever else sits in the back office deciding what is true about your business. This is where business logic lives. This is where you compute. If a price depends on the customer’s contract tier, the contract tier is decided here. If a discount depends on the contents of the cart and the phase of the moon and the customer’s lifetime value, that decision is made here. The before is yours, completely, the way the old checkout used to be yours.

Cross-section of three adjacent rooms: a busy workshop on the left, a bare display room in the center with a single window, and a reaction room on the right. A small figure stands in the threshold between the workshop and the display room.
The before computes. The middle shows. The after reacts. The figure is deciding which room their code actually lives in.

The middle is the checkout itself, and the middle is not yours. It is Shopify’s. It is sandboxed—your code runs in a constrained environment with a performance budget and a security boundary, not in the open field the before gives you. It is audited, in the sense that a security team can account for every piece of code that executes in a buyer’s payment session, which is a feature, not an inconvenience, even though it will feel like an inconvenience the first time it stops you. In the middle, things get shown. The price that was computed in the before gets displayed. The discount that was decided upstream gets rendered. The delivery date the buyer needs to pick gets presented as a field. The middle is a window, not a workshop.

The after is everything that happens once the order exists: Shopify Flow, webhooks, app actions, the whole machinery of reactions. The order has been placed; now the warehouse gets notified, the ERP gets the record, the loyalty points get awarded, the gift note gets routed to the fulfillment system. This is where reactions live. If you find yourself wanting to do something as a consequence of a purchase, the after is almost always where that something belongs, and almost never the middle.

Compute upstream, render downstream. Say it twice, because the rest of this chapter is just that sentence applied to specific mistakes. Compute upstream, render downstream. The before computes. The middle renders. The after reacts. When a checkout build goes wrong, it is nearly always because someone tried to make the middle do the work of one of the other two.


There are two ways that mistake shows up, and they are worth naming individually, because they feel different from the inside even though they are the same error.

The first is duplicating a primitive Shopify already gives you. The canonical version of this, the one we have watched more than one team build with great care, is the Cart Validation Function that calls the warehouse for live stock at checkout time. The reasoning is airtight, if you grant the premise. We can’t oversell. Overselling is a customer-service nightmare and a finance headache. So at the moment of truth—checkout—we will reach out to the WMS, ask it whether the item is really, truly in stock right now, and block the order if it isn’t. Every word of that is sensible. And it is Magento muscle memory in its purest form, because it assumes the platform’s own inventory numbers can’t be trusted and the only safe source of truth is the warehouse, queried live, in the buyer’s session.

But Shopify already has inventory. It already tracks stock levels, it already decrements them, it already prevents overselling when you let it. The reason the team doesn’t trust those numbers is not that the numbers are untrustworthy—it’s that the sync between Shopify and the warehouse is unreliable, and rather than fix the sync, the team built a live-stock check at checkout to paper over it. Now there are two sources of truth, a fragile real-time call sitting in the critical path of every purchase, and a new failure mode where the WMS is slow or down and checkout slows or breaks with it. The fix was never a Function. The fix was making the upstream sync reliable, in the before, so that Shopify’s own inventory could be believed—which is the only place the problem could actually be solved, because it is the only place the problem actually was.

A patched, leaking garden hose leads to a holey bucket placed under a dripping tap. A perfectly good, unused watering can stands beside it.
The patch on the hose is not the fix. The fix is the hose. But the bucket looked like a faster afternoon.

The second way the mistake shows up is computing inside the checkout—doing the arithmetic in the middle instead of upstream. A pricing rule that’s almost expressible in Shopify’s native discount tools, so a team reaches into the checkout to finish the job in code. A tax nuance, a bundle calculation, a contract-specific adjustment, computed live in an extension because that’s where it felt natural to put it. The discipline here is exact and it is the same every time: decide the thing in the system of record, push the result into Shopify as a metafield or a line item property or a draft order attribute, and let the checkout render the result it was handed. The pricing engine decides the price; Shopify shows the price. The ERP decides the discount; Shopify shows the discount. Nobody computes in the middle. The middle was never built to compute. It was built to show.


Now, before the engineering lead reading this at eleven at night—the one who has a Cart Validation Function half-written in another tab, the one who already knows their WMS sync is held together with a cron job and a prayer—before you close this and go fix the sync, one honest qualification, because the rule has a real exception and pretending it doesn’t would make me a liar.

Shopify Functions exist, and they are good, and you should use them. The exception is this: Functions are for Shopify-shaped logic. Discount combinations. Shipping method filtering. Payment method visibility. The decisions that are genuinely about how Shopify’s own checkout behaves and that have nowhere else sensible to live—those belong in Functions, and Shopify built Functions precisely so they’d have somewhere to live. The error is not using Functions. The error is bending them into a general-purpose business rule engine—using a Function to reach out to your WMS, to call your pricing service, to run logic that is about your business rather than about Shopify’s checkout. A Function that filters which shipping methods appear is using the tool as designed. A Function that phones the warehouse is the tool screaming. Learn the difference, because the difference is the whole discipline, and the platform will not teach it to you—it will simply let you build the wrong one and pass QA.


Which brings us back to the gift, and to the part of this that teams genuinely do not see coming, because it is invisible in exactly the way the failed validation was invisible. The buyer’s journey through the checkout is not one path. It is several, and they do not all run the same code.

The path the team builds against—the one in the demo, the one in QA, the one in everyone’s head when they say “the checkout”—is the full-page flow, the one where the buyer lands on a checkout page and your UI Extension renders and your date picker appears and your block_progress gets a chance to do its job. But that is not the path the money takes. The money increasingly takes Accelerated Checkouts: Apple Pay, Shop Pay, the one-tap wallet flows that convert better than anything else you have precisely because they skip almost everything. They skip your UI Extensions. They do not render them—not “render them and ignore them,” do not render them, at all. No block_progress is ever checked, because the block was attached to a field that was never drawn. The buyer paying with Apple Pay went from cart to confirmation without your code getting a turn, and your validation, living in the UI layer, simply wasn’t part of the conversation.

These are the invisible seams of the checkout lifecycle, and teams find them in production, which is the worst possible place to find them, because production is where the orders with no delivery date are already piling up. The seam is not a bug. It is the design. The wallet flows are fast because they are spare, and they are spare because they refuse to run arbitrary UI in the buyer’s path.

So where does the validation go? It goes down a layer, to the data. The delivery-date requirement has to be enforced by a Cart Validation Function—which runs regardless of which buyer path is taken, because it lives at the data layer that all of them pass through, wallet or not. The UI Extension still renders the date picker, because the buyer still needs a way to choose the date, and a graceful place to do it. But the picker is no longer the thing doing the enforcing. The Function enforces. The picker affords.

The Function is the contract; the UI Extension is the affordance. Say that one twice as well, because it is the sentence that would have saved two sprints. The contract—the rule that must hold, no matter how the buyer arrives—lives in the Function, at the data layer, where every path is bound by it. The affordance—the friendly, visible way the buyer satisfies the rule—lives in the UI Extension, where the buyers who see it are helped by it. Build only the affordance, and the wallet flows route around your rule. Build only the contract, and the buyers who do see a UI get a blank rejection with no way to comply. You need both, and you need to know which is which, and the team that lost two sprints knew neither, because on their old platform the distinction didn’t exist—the UI and the rule were the same code in the same request, and they had never once had to ask where a rule actually lives.

A heavy closed padlock and an elegant door handle sit side by side on a flat surface, separated by a small gap. Neither is attached to a door.
The lock enforces the rule. The handle shows you where to push. Build only one, and you have either a mystery or a lie.

It would be easy to read all of this as Shopify taking something away, and to feel about it the way you feel about any capability you used to have and no longer do, which is robbed. Resist that, because it misreads the trade. The constraints are the price of admission, and the admission buys something real. The middle is sandboxed and performance-budgeted and security-audited because that is what lets Shop Pay convert the way it does, what lets a buyer trust a one-tap purchase, what lets your own security team account for every line of code that executes in a payment session without auditing a decade of accreted checkout customizations nobody fully remembers writing. The old platforms let you run anything in the checkout, and the bill for that freedom came due as fragility, as audit burden, as the slow accumulation of code in the most sensitive part of the funnel that everyone was afraid to touch. Shopify took the freedom and gave back the guarantees. Whether that’s a good trade depends on what you were doing with the freedom, and most enterprise teams, if they’re honest, were using it to paper over an unreliable sync.

This is the same shape as so much of the rest of the platform, and by now you have seen the shape enough times to recognize it on sight: the workaround always looks small in the planning doc, and it rarely stays small. The live-stock check is one Function, in the planning doc. In production it is a standing dependency in the critical path of every order. The validation in the UI layer is one extension, in the planning doc. In production it is two sprints and a week of mystery orders and a buyer paying full price for a gift that will arrive whenever the warehouse gets around to it, because nobody told the warehouse when.

The teams that replatform well are not the ones who customize the checkout cleverly. They are the ones who stop asking how do I customize the checkout and start asking where in this lifecycle does this customization belong—before, middle, or after—and then put it there, and only there. The before computes. The middle shows. The after reacts. The Function holds the contract; the extension offers the affordance. And the assumption you carried in from the old platform—that the checkout is yours to run code in—does not survive contact with this one, which is why the work is not to migrate the code. The work is to migrate the assumption. Migrate the assumptions, not just the code, because the code can be rewritten in an afternoon and the assumption will cost you two sprints and a production incident before you even notice you still hold it.

You owe the buyer paying with Apple Pay the same rule as everyone else. They will not see your date picker. Make sure they’re still bound by your contract.

Here lies the date-picker validation. Two sprints. Two layers. block_progress, declared. The wallet flow never saw any of it.