“Multiplatform” is one of those words that gets used to mean three different things. iOS-and-Android multiplatform is mostly a UI problem. Server-and-client multiplatform is mostly a serialization problem. This kind of multiplatform — one SaaS product that has to live inside both a heavily-walled garden and the totally open web — is mostly a trust-model problem, dressed up as an engineering problem. The two environments hand you completely different primitives, and the product is supposed to look like the same product anyway.
Split Test Pro runs A/B tests on Shopify stores via an installable app, and on generic HTML sites via a <script> tag. Same dashboard, same Bayesian stats engine, same Claude features. Underneath, almost every input is shaped differently. This post is about the seams: where the code branches, where it doesn’t, and one decision I’m still slightly defensive about — using a switch statement on a platform-type enum instead of an interface.
What’s actually different (it’s more than you’d think)
If you’ve only worked on one of these, the other side’s constraints can be invisible. Here’s the rough map of what changes between a Shopify experiment and an HTML experiment:
| Shopify | HTML (arbitrary site) | |
|---|---|---|
| Install | OAuth into the merchant’s store. We exist in Shopify’s app catalog; uninstall is a webhook. | Merchant signs up, gets a script tag, pastes it into their <head>. There is no install event. |
| Auth into our app | App Bridge / session token signed by Shopify. We trust their JWT. | Email + OTP. We mint our own JWT. |
| Event ingestion | A Web Pixel — sandboxed in an iframe, subscribed to analytics.subscribe("all_events", ...). Shopify hands us the events; we forward them. | An inline script tag with our SDK. The merchant calls window.SplitTestPro.track(goalKey) on whatever event they care about. |
| Free funnel data | Checkout funnel out of the box — checkout_started, payment_info_submitted, checkout_completed. ShopifyQL is available for richer queries. | Nothing. The merchant defines their own goals; we ingest whatever they tag. |
| Revenue tracking | Comes free, in the merchant’s store currency, from checkout events. | Merchant has to pass it: track("purchase", {value: 49.99}). Currency conventions are on them. |
| Trust boundary | Shopify guarantees the install is real and the events are real. | We guarantee nothing. Anyone with the script tag can spoof events. |
| Subscription / billing | Shopify’s billing API. They handle the card. We listen for status webhooks. | Stripe. We handle the card. |
The bottom row is the one that matters most architecturally and gets talked about least. On Shopify, the trust boundary lives inside Shopify — the merchant can’t lie to us about whether they paid, because Shopify is between us and them. On HTML, we own that whole boundary ourselves. Two different worlds, dressed up as the same dashboard.
Where the platform type lives
The very first decision was where to encode “what platform is this workspace on?” The answer ended up being: in three places, and that’s fine.
In Go, it’s a string enum:
type PlatformType string
const (
Shopify PlatformType = "shopify"
BigCommerce PlatformType = "bigcommerce"
Magento PlatformType = "magento"
WooCommerce PlatformType = "woocommerce"
HTML PlatformType = "html"
)
Three of those are aspirational — only Shopify and HTML actually ship. That’s deliberate. Having the enum already widened to five values forces every switch in the codebase to have a default case, which forces a decision the first time someone tries to add BigCommerce: do we error out (current behavior) or do we extend the switch? Neither is wrong, but the compiler can’t make you choose if there’s only one value.
In Postgres, platforms are a small table joined to workspaces via a junction:
-- 002_workspaces.up.sql
CREATE TABLE platform (
id SERIAL PRIMARY KEY,
name platform_name NOT NULL UNIQUE
);
CREATE TABLE workspace_platform (
workspace_id UUID REFERENCES workspaces(id) ON DELETE CASCADE,
platform_id INT REFERENCES platform(id),
PRIMARY KEY (workspace_id, platform_id)
);
And then the platform-specific data lives in its own table: platform_shopify holds the shop domain, access token, scopes; platform_html holds the script-tag ID and verification status. A workspace links to exactly one row in exactly one of those tables. (Yes, this is denormalized polymorphism. No, I haven’t regretted it. The alternative — a wide table with mostly-null columns, or a JSONB blob — is worse in different ways.)
In the platform-detection function, at the auth seam:
func (s *Service) DeterminePlatform(iss string) common.PlatformType {
if strings.Contains(iss, ".myshopify.com") {
return common.Shopify
}
// HTML platform uses "splittestpro" as the issuer.
if iss == "splittestpro" {
return common.HTML
}
return ""
}
Every incoming session token carries an iss claim. If it ends with .myshopify.com, Shopify issued it; otherwise we did. From that one decision, the workspace’s platform is known for the rest of the request.
The interface I almost wrote (and what I did instead)
Early on, the obvious-feeling design was an interface — something like Platform { Install(); Uninstall(); GetSubscription(); ... } — implemented by a shopifyPlatform and an htmlPlatform, with the rest of the app holding a Platform reference and never knowing which one it had.
I started building it that way. Then I deleted it.
Two things pushed me toward a flatter design with switch statements:
The methods don’t actually have the same shape. Shopify install takes a JWT and an issuer URL and talks to Shopify’s GraphQL API; HTML install is a no-op (the merchant just pasted the script tag). Shopify subscription status comes from a webhook; HTML subscription status comes from a Stripe customer record. Forcing both behind one signature meant either a fat interface (Liskov violations everywhere) or a thin one (every implementation immediately took the parameters out of an interface{} bag). Neither was an improvement.
The branches are short and the dispatch points are few. There are maybe six places in the codebase where platform actually matters: install, uninstall, subscription status, billing, event ingestion, and a handful of feature checks. Each branch is short. switch platform { case Shopify: ...; case HTML: ...; default: return error } reads cleanly, and the compiler reminds me to handle the default. The polymorphic version would have hidden the dispatch in vtable lookups for no real win.
So what shipped looks like this:
func (s *Service) Install(
ctx context.Context,
platform common.PlatformType,
workspaceID uuid.UUID,
jwtToken string,
issuer *url.URL,
) error {
switch platform {
case common.Shopify:
return s.shopifyService.CheckInstall(ctx, workspaceID, jwtToken, issuer)
case common.HTML:
return s.htmlService.MarkInstalled(ctx, workspaceID)
default:
return errors.New("platform not supported")
}
}
The shopifyService and htmlService dependencies still get their own internal abstractions — they’re real packages with real types — but the dispatch layer is just a switch. Honestly, the second time you find yourself fighting a polymorphic abstraction to handle a case where the two implementations want different parameters, that’s the signal to flatten it.
Event ingestion: two very different pixels, one ingest endpoint
This is the seam I’m most happy with. The two platforms have totally different event collection mechanics:
On Shopify, we ship a Web Pixel extension. Shopify loads it into a sandboxed iframe on every storefront page, and our code does:
analytics.subscribe("all_events", (event) => {
sendEvent({
workspace_id, experiment_id, variant_id,
event_type: event.name,
data: event.data,
});
});
analytics.subscribe("split_test_pro_experiment_activated", ...);
analytics.subscribe("advanced_dom_clicked", ...);
analytics.subscribe("advanced_dom_scrolled", ...);
Shopify hands us a curated event stream including the entire checkout funnel — without us needing the merchant’s cooperation. The variant ID comes from a cookie we set when the visitor first hits a tested page.
On HTML, the merchant pastes a script tag, and our SDK exposes:
window.SplitTestPro.track("add_to_cart");
window.SplitTestPro.track("purchase", { value: 49.99, currency: "USD" });
Two completely different APIs on the client. But both POST into the same backend ingestion endpoint, with the same shape: { workspace_id, experiment_id, variant_id, event_type, data, timestamp }. The backend stores them in the same InfluxDB measurement, queried by the same code.
This is the lesson, generalized: branch at the boundary, share the core. The boundary is the page — that’s where the two worlds genuinely differ, and that’s where two SDKs is the right answer. Past the boundary, normalize to one event shape and stop caring. Trying to share an SDK between the two would have meant fighting Shopify’s iframe sandbox; trying to keep separate stores past the boundary would have meant maintaining two stats engines.
Conditional content as a soft platform branch
Most of the product is platform-agnostic by the time data has reached the stats engine. Sessions are sessions, conversions are conversions. But two places still need to know what platform they’re on, and the way they handle it is interesting:
The results-analysis Claude prompt branches on platform when building its data section. For Shopify it appends the checkout funnel and revenue metrics; for HTML it appends whatever custom goals the merchant defined:
if exp.Platform == common.Shopify {
appendShopifyFunnel(b, results) // checkout_completed, add_to_cart, ...
appendRevenueMetrics(b, results)
} else {
appendCustomGoals(b, results)
}
appendDailyTrend(b, results)
The prompt itself doesn’t say “this is a Shopify experiment” or “this is an HTML experiment.” It just gets fed whatever metrics are available, and Claude reasons about them. This is the cheapest possible version of platform-awareness: the data structure differs; the consumer doesn’t have to care.
Feature visibility in the frontend is the other place. Shopify-only features (ShopifyQL queries, advanced DOM event types, the Web Pixel install link) get hidden when a workspace is on HTML — not stubbed, not greyed out, hidden. Their gracefully-degraded version isn’t “show a disabled button”; it’s “this UI element does not exist for you.” Putting a disabled “ShopifyQL Query” button in front of an HTML merchant would be cruel; it tells them they’re missing something they can’t ever get.
The opposite direction matters too: HTML’s custom-goal definition UI shows up for Shopify merchants when they want to track non-checkout events, because more capability never hurts.
Where this design pays off
Two anecdotes about what this architecture actually buys you:
Adding the Claude features cost the same on both platforms. The anomaly detector and the results analyzer both consume the same normalized ExperimentResults shape. When I added them, neither feature had to know whether it was looking at a Shopify experiment or an HTML one. The platform branch happens in one place — the prompt builder — and even that’s small. If the two platforms didn’t normalize, every AI feature would have to be written twice.
The Bayesian stats engine has zero platform code. It takes Distribution objects and produces probabilities and credible intervals. It doesn’t know what a Shopify checkout is. It doesn’t know what a script tag is. That’s because all the platform-specific normalization happens upstream, in the results-building layer. The math layer never had to grow a switch statement.
That’s the test, retroactively: how deep into the codebase does the platform abstraction reach? If it reaches the math layer, you have a leaky abstraction. If it stops at the data-shaping layer, you’ve drawn the line in the right place.
What I’d reconsider
The currency story on HTML is loose. Shopify gives us a currency code with every order; HTML merchants pass whatever they want as value. We accept the number, store it, treat it as if it were dollars. If a merchant runs a multi-currency site we’ll silently average pounds and yen. That’s an honest hole — and on Shopify it doesn’t exist because the platform fills it for us. The fix is either to require currency on the HTML SDK or to normalize at ingestion. I haven’t picked.
The trust model on HTML deserves more. A bored visitor with the browser dev console can spoof any number of track("purchase", {value: 1e9}) calls. We do some plausibility filtering at ingestion (rate caps, outlier rejection in the stats), but a determined adversary can poison an experiment. On Shopify this is structurally harder because the Web Pixel runs inside Shopify’s sandbox. The mitigation on HTML is probably a per-workspace HMAC of high-value events from the merchant’s server — but the friction of asking merchants to do server-side signing is real.
The four-not-shipped platforms in the enum should probably leave it. They were aspirational. They make the switch statements slightly noisier. If BigCommerce or WooCommerce becomes a real plan, adding the enum value is one line; carrying them around in the meantime is overhead with no payoff.
The headline, again
The trap with “multiplatform” code is reaching for polymorphism the moment you have two cases. It feels principled — and then six months in, you’re maintaining a five-method interface where one implementation no-ops three of the methods, and the abstraction is more pattern than substance. The thing that actually works is more boring: normalize at the right depth, branch only where the worlds genuinely differ, and let the compiler-checked switch statement do the dispatch. Five years from now I bet the only piece of this design that’s actually load-bearing is the normalized event shape past the ingestion boundary. The dispatch layer can be rewritten in an afternoon any time it gets in the way — and that’s the property to optimize for.