HackAIAI Engineering · Gold Coast
BlogTechsResourcesGet in touch

Writing / case-study

Six decisions, five of them reversed, and one we never made at all

A multivendor marketplace went from a custom platform, to native Shopify, to Shopify's own headless stack, to a hand-rolled headless setup with Stripe carrying payments. Every technical decision was reasonable at the time. The one that mattered most was never made deliberately at all.

By Javier Hack · 2026-08-19 · 12 min read

Architecture

Shopify

E-commerce

Decision Records

Why this is worth writing down

We rebuilt this marketplace's core architecture four times in five years. Not four features - four times the answer to "how does checkout and identity work" changed underneath the product. Each change was the right call given what we knew at the time. Read back as a sequence, though, there is a pattern worth naming, and it is not "we made bad decisions." It is closer to: the cost of a platform choice does not show up until you hit the one feature the platform was not built for, and by then you have already built on top of it.

This is the decision log I wish I had written at the time. Writing it after the fact means I get to add the part a real-time ADR cannot: what actually happened next.

Decision 1: build it ourselves

Context. The first concept was not a marketplace. Vendors published a profile, customers discovered them and requested more information, and a conversation followed - no cart, no checkout, nothing resembling commerce yet. Both customers and vendors got their own logins to manage that conversation.

Decision. Build it fully custom: Next.js frontend, a Node/TypeScript API, MongoDB, Cognito for identity. Full control, and reasonably so - there was no commerce yet, and no off-the-shelf marketplace platform would have fit a product that was not doing commerce in the first place.

Consequence. The architecture was fine. The business model was not. A directory where customers message vendors and wait for a reply is a slower, weaker product than one where they can actually buy something. The platform worked as built; it just was not the right product to have built.

Decision 2: move to native Shopify

Context. Once the model needed to become a real marketplace - browse, cart, buy, multiple vendors fulfilling many orders - building and maintaining all of that ourselves stopped being worth it. We had already learned the cost of building it ourselves once. Shopify does commerce, and choosing it was explicitly choosing not to become a commerce engineering team a second time.

Decision. Rebuild natively inside Shopify: less code to own, less infrastructure to maintain, a mature checkout for free.

Consequence. Blocked, but not right away, and that timing is the actual story. Mostly, Shopify worked exactly as expected - product catalogue, admin, hosted checkout, all of it fine, right up until the one thing it did not do: split a single cart across multiple vendors into separate orders without moving to Shopify Plus, which was not a scalable cost for where we were. We had not spiked that specific question before committing, so we found out mid-build rather than up front. By then we were far enough in that stopping felt more expensive than continuing, so we kept going instead of stepping back - every workaround we found afterward lived in a plugin or in Liquid, and none of them actually solved multi-vendor checkout, they only worked around its absence.

This is the lesson that outranks the four technical ones after it: the miss was not choosing Shopify - that was a reasonable call, made for a reasonable reason. The miss was not testing the one constraint the whole business depended on before enough was built that reversing course had a real cost attached to it. "Shopify does commerce" was true and irrelevant; the question was whether Shopify does this commerce, and it needed an answer while it was still cheap to get one, not after momentum made the answer expensive to act on.

Decision 3: go headless, the way Shopify suggests

Context. Headless still meant staying inside the Shopify ecosystem for products, inventory and orders, just with our own frontend instead of a Shopify theme. Two separate problems pushed toward Shopify's own suggested stack at the same time, and together they made the decision feel obvious rather than risky. First, our existing Next.js app was fully client-side, and commerce mostly cannot afford to be - product pages need to be server-rendered to be indexed and ranked properly, and we had not built that. Second, we could not find a clean, documented way to integrate our existing frontend directly with Shopify's own customer identity flow. Hydrogen and Oxygen had both solved out of the box: server-side rendering by default, and identity already wired in.

Decision. Migrate the frontend off Next.js/Material UI/Vercel and onto Hydrogen and Oxygen.

Consequence. About four months, and by the end we called it what it was: a waste. Hydrogen and Oxygen were different enough from the stack we actually knew - Next.js, Material UI, Vercel's deployment model - that the migration never stopped being a fight against unfamiliar tooling, on top of migrating real product code. We were not becoming more productive in the new stack faster than we were losing productivity from leaving the old one.

The lesson that mattered later: "the vendor's suggested stack" is optimized for the vendor's common case, not for the expertise a specific team already has. The question is not "is this stack good" - it almost certainly is - it is "does the migration cost less than what we already know is worth." We answered that one backwards, and found out four months in rather than at the start. It is also worth naming why the decision felt so safe going in: it was not solving one problem, it was apparently solving two at once - SEO and identity - and a solution that answers two open questions simultaneously is exactly the kind of thing that stops getting scrutinised as hard as it should.

Decision 4: headless again, on our own terms

Context. Both problems that had pushed us toward Hydrogen turned out to be solvable directly, on the stack we already knew. Server-side rendering in Next.js was never actually out of reach - we had just never built commerce pages that needed it before, and once we learned to do it properly the SEO problem went away without a framework change. (That is genuinely its own post.) The identity gap closed the same way: once we spiked it properly, integrating Next.js against Shopify's GraphQL Storefront and Admin APIs, including customer authentication, worked well - and once it did, Shopify's identity was the obvious choice on its own merits, not just as the path of least resistance. It integrated smoothly with the Shopify backend we were already committed to, it was genuinely easy to implement, and it gave us passwordless login out of the box as the default, which was exactly what we wanted for customers.

Decision. Drop Hydrogen and Oxygen. Rebuild headless on the stack we already knew - Next.js talking to Shopify over GraphQL - using Shopify's own identity for customer login instead of maintaining our own.

Consequence. This is the one that stuck. It also quietly paid down a debt from decision 1: we retired Cognito for the customer-facing surface entirely, since Shopify's identity now covered that job, and kept Cognito only for the internal admin app, where it had never been a problem.

The lesson that mattered later: the hard integration problem that sends you looking for a bigger framework is sometimes just an under-documented integration problem. Worth spiking in isolation before adopting the framework that happens to have already solved it for you.

Decision 5: split payments out from Shopify checkout

Context. With the frontend problem solved, the original blocker from decision 2 was still sitting there: Shopify's hosted checkout still could not split one cart into per-vendor orders without Shopify Plus. We now controlled the frontend completely, which meant we no longer had to accept Shopify's checkout as a package deal.

Decision. Bring in Stripe and own the payment flow directly: a single checkout customised (e.g. upsell with donations) experience for the customer, and once payment succeeds, the backend creates one order per vendor.

Consequence. It took real effort to learn Stripe's integration surface properly - webhooks, payment intents, reconciliation - but it delivered exactly the flexibility multi-vendor commerce actually needed, which no amount of Shopify plugin work had.

Where it landed

The shape that survived: Stripe carries payments and the multi-vendor order split; Shopify's own identity handles customer login; Shopify remains the commerce backend for products, orders and transactional email; MongoDB sits in front of Shopify's product data as a cache, so a product page view does not mean a live call to Shopify every time. Cognito is retired everywhere except the internal admin app.

The system kept evolving from there - an API layer, background workers, and eventually an AI-assisted product search sit on top of this same foundation, and how we actually got server-side rendering right in Next.js for a commerce catalogue is its own story. Each is worth telling separately.

Two decisions that did not need five acts

Not every reversal in this system took months to unwind. Two are worth naming precisely because they were cheap to reverse, and that is the lesson.

We adopted Twilio SendGrid for transactional email, and the integration effort turned out to be larger than the value it returned at our stage - Shopify's own transactional email already covered what we actually needed. We cancelled it and went back to native. Separately, we tried adopting a CRM, and hit the same shape of problem from a different angle: it was overkill for where the product was, adding process weight before there was enough volume or team to justify it. Both were dropped quickly, with little sunk cost, because we noticed early rather than after committing further build effort on top.

That is the difference between these two and the Hydrogen migration: not that the decisions were better reasoned going in, but that the cost of being wrong was caught before it compounded. A tool adopted and then dropped after a few weeks is a note in a decision log. A framework migrated onto and then abandoned after four months is a chapter.

Identity, in the end, is a per-project decision, not a rule

One more reversal worth naming, because it looks like an inconsistency and is not. Later, building a separate vendor dashboard, the identity choice was Entra ID rather than Shopify's customer identity - picked for ecosystem fit with where new work was heading, not because of a rule that the whole system must agree on one identity provider. Shopify's identity handles customers, Entra ID handles the vendor dashboard and what follows it, and Cognito is legacy on the admin app, on the roadmap to migrate to Entra ID.

What I would tell myself at the start

Find the one feature the business cannot live without, and test it against the platform first. Not the common case - the load-bearing one. Multi-vendor checkout was that feature twice, and both times we found the constraint after building toward the platform rather than before.

A framework that solves your hardest integration problem is not free just because the problem is hard. Check whether the problem is actually solvable on your own stack before adopting someone else's, especially when "someone else's" means leaving tooling your team has years of muscle memory in.

The more problems a single decision appears to solve at once, the harder it deserves to be scrutinised, not the less. Hydrogen looked like it solved SEO and identity in one move, and that is precisely what made it feel obviously correct instead of merely convenient. Both problems turned out to be solvable separately, on the stack we already knew.

Consistency across a system is a choice, not a default. Two identity providers is fine when each was chosen deliberately for what it authenticates. Legacy debt with a migration path is a different thing, and it is worth calling it that rather than dressing it up as a decision.

None of these five decisions were foolish at the time they were made. That is the actual point of writing them down this way - the failure mode here was never bad judgment in the moment, it was not having a way to check a decision against the one constraint that would end up mattering most.

The same lesson, one level up

Here is where it actually landed: the platform repositioned toward impact and away from physical e-commerce. The original business model - the one all five decisions above were built to serve - did not hold up. That sentence, on its own, could read as this whole post amounting to nothing.

It does not, and the reason is worth stating precisely. The platform is running well today, with solid automated test coverage, and it still reuses the model, the architecture and the data from everything described above. Nothing here was thrown away when the business model changed; the system underneath was general enough to carry a different product on top of it. That is not an accident. It is the payoff of every decision in this post having been made about the architecture on its own terms - correct identity boundaries, a checkout that actually handles multi-vendor splits, a data layer that does not assume one specific product story - rather than being wired tightly to the assumption that the original model would be the permanent one.

Decision 1 already said it about a single business model: the architecture was fine, the business model was not. It turns out that was not a one-off observation about an early mistake. It is the same sentence again, now about the whole platform, and it held up the second time for the same reason it held up the first: architecture built to be correct outlives whichever product story it was first built to serve.

What this actually changed about how I work

It would be easy to read all of this back and conclude the lesson is "research more before deciding." That is true, but too soft to be useful on its own - everyone agrees research is good, and it does not stop anyone from repeating decision 2 or decision 3. The specific practice that came out of this, and the one I now run at the start of every project rather than after the fact, is narrower than "do more research": find the single constraint the product cannot survive without, and spike it in isolation before choosing a platform around it. Not a proof of concept of the easy path - a deliberate attempt to break the hardest one. Multi-vendor checkout would have taken an afternoon to falsify against Shopify's native checkout, and a few days to falsify against Hydrogen versus our own stack. We spent months finding both out the slow way instead.

That is the difference between a lesson and a practice. The lesson lives in a retrospective. The practice shows up before the next decision gets made, which is the only place it is actually worth anything.

Related topics

React

MongoDB

Node

Design Patterns