DEV Community

MinSoo Kim
MinSoo Kim

Posted on

The checkout never calls our server: how HideKit's rules run inside Shopify

HideKit is a small Shopify app. A merchant writes rules like "hide cash on delivery for orders under ₩100,000 shipped to Korea" or "rename Standard Shipping to Ground for US addresses", and the checkout follows them. If ten thousand merchants installed it tomorrow, our server would not get one extra request at checkout time. That is the part I want to explain, because it decided almost everything else about how the app is built.

Where the rules live

The rules do not live in our database. They live in one JSON document in a Shopify metafield attached to the merchant's payment customization, under the namespace $app:payment-rules with the key function-configuration. A second customization holds the delivery rules. Our admin app writes to that metafield, and nothing else of ours is involved after that.

At checkout, Shopify runs a Function. Ours targets cart.payment-methods.transform.run, and a sibling targets cart.delivery-options.transform.run. Shopify hands the function an input it asked for in a GraphQL query: the cart total and currency, discount applications, the buyer's company if it is a B2B checkout, the customer's amount spent, order count and tags, the delivery address down to province and zip, each line's weight and collection membership, and the checkout language. The function reads the metafield, evaluates the rules against that input, and returns a list of operations: hide this method, rename that one, move this one to a different position. Three operation types, one metafield read, zero network calls. That is the whole runtime. If our servers are down, the checkout does not notice.

The function is compiled to WebAssembly and executed by Shopify, so the engine had to be plain TypeScript with no I/O. That constraint turned out to be a gift.

One engine, two callers

Because the engine is a pure module, shared/rules-engine, the admin app can import the same code. HideKit has a page called the Rule Tester where a merchant types in a cart: country, total, products, tags, and a list of payment methods (the default is three: cash on delivery, credit card, bank deposit). The page runs the merchant's saved rules through the same evaluateRules function the checkout uses and shows which rules fire and what each method would look like. No test orders, no fake customers, and no chance that the tester and the checkout disagree, because there is only one implementation.

"Why isn't it hidden?" is the support question every app in this category gets. The tester exists so the merchant can answer it themselves before a customer ever sees the checkout. Which method do your customers keep picking that you wish they wouldn't? That is usually the first rule a merchant writes.

Testing a thing you cannot step through

You cannot attach a debugger to a function running inside Shopify's checkout. So the tests run the compiled wasm against fixtures. There were 89 tests in the suite at the last count I wrote down, including an 18-case condition matrix run through the wasm build and boundary fixtures such as a rule at 20 with carts at 20 and 19.99, because "greater than or equal" is exactly the kind of thing that gets flipped in a refactor.

The end-to-end check was done by hand on a development store with a rule that reads "country is KR AND total is at least ₩100,000": a ₩58,000 cart showed cash on delivery, a ₩116,000 cart hid it. Boring, and the only proof that matters.

The incident that became a smoke test

Cleaning up a development preview once removed the payment customization that owned the metafield. The rules were still visible in the merchant's admin, pointing at an owner that no longer existed. The fix was to recreate the customization with the released function's ID, and doing it through the app's own UI turned the fix into a full pass of the create path: UI to server to the customization-create mutation to metafieldsSet. It is the most useful bug we have had, because it forced the exact flow a new merchant goes through.

What the architecture cannot do

Living inside Shopify means living with Shopify's limits, and we list them in the app instead of discovering them with a merchant. On non-Plus stores, checkout does not allow hiding the Shopify Payments gateway itself; only individual methods like cash on delivery or manual payments can be hidden. There is also a known checkout bug where a customer who selected a method that then becomes hidden can get stuck, so onboarding flags it up front. Two limits, both written on the listing, because a refund request is a worse place to learn about them.

I would build it this way again. The rules being the merchant's data, in their store, evaluated by the platform, is not only a nice property for uptime. It means uninstalling the app leaves nothing behind in the theme, and it means the thing the merchant tests is the thing that runs.

If you want to see the tester, the listing is here: https://apps.shopify.com/hidekit.

Sources

Top comments (0)