Illustration of a marketplace storefront with a product listing and a group of buyers

Building a marketplace MVP that survives its own success

Most marketplace rewrites are paid for by three or four decisions made in the first six weeks. Here is how to model orders, money, and suppliers so the MVP still holds once volume arrives.

Every marketplace rewrite I have watched was paid for by three or four decisions made in the first six weeks — usually decisions nobody argued about, because at the time they were obviously fine.

The MVP itself is rarely the risk. The risk is the shape it leaves behind: the table other teams start joining against, the JSON column that quietly became an API contract, the status string that half the business logic now branches on. Those are cheap to write and expensive to move once orders, invoices, suppliers, and a finance team depend on them.

What follows are the decisions that earned their keep when a marketplace went from MVP and go-live to real order volume, several supplier integrations, and a roadmap that included other countries. None of them are exotic. All of them are much cheaper on day one than in month eighteen.

Model the order as a sequence of facts

The fastest way to model an order is a row with a status column. It works until the first refund. Then partial fulfilment. Then a supplier who ships two of three lines, and finance asking what the order looked like on the 14th.

The cheap version of event sourcing — no framework, no CQRS, no separate read model — is to keep the row and append the facts next to it:

ts
type OrderEvent =
  | { type: 'order.placed'; at: string; lines: OrderLine[]; total: Money }
  | { type: 'payment.authorized'; at: string; amount: Money; providerRef: string }
  | { type: 'payment.captured'; at: string; amount: Money; providerRef: string }
  | { type: 'shipment.dispatched'; at: string; lineIds: string[]; carrier: Carrier }
  | { type: 'refund.issued'; at: string; amount: Money; reason: RefundReason };

The orders row keeps a derived status so queries and list views stay trivial. The event table keeps why it reached that status. Append the event and update the derived row in the same transaction and you have added maybe two days of work to the MVP. In exchange:

  • Reconciliation stops being an engineering task. Finance can rebuild any order at any point in time without asking anyone for a query.
  • Support sees a timeline instead of a single word, which removes a whole category of "can you check in the database" requests.
  • Debugging gets cheap. A bug in status derivation is a replay away from being confirmed, not a guess reconstructed from application logs.
  • Integration handlers become append-only, which is what makes them safe to retry — and every integration will be retried.

The trap to avoid is letting the event log become a second, competing source of truth with its own subtly different rules. One writer, one transaction, one derivation function that lives in the domain layer and is covered by tests. If you cannot state in one sentence how status is derived, the model is already drifting.

Money is not a number

A field declared as price: number is a decision to rewrite billing later. Two things make money hard, and neither is arithmetic: money is always in a currency, and in most of Europe every customer-facing amount exists in both a net and a gross form, produced by a tax rate that can change while your orders keep living.

ts
interface Money {
  /** Minor units. 1999 is EUR 19.99. Never a float. */
  amount: number;
  currency: CurrencyCode;
}

interface LinePrice {
  net: Money;
  gross: Money;
  taxRate: TaxRateId; // 'DE_19' | 'DE_7' | 'AT_20' | ...
  taxAmount: Money;
}

Three rules carry most of the weight:

  1. Store amounts as they were charged. Never recompute a historical total from a current tax rate or a current catalogue price. The order is a record of an agreement, not a projection of today's configuration.
  2. Persist the tax rate that was applied, not a reference that resolves to whatever the rate is now. Rates change, and invoices from before the change must still add up.
  3. Round once, at a documented level. Per line or per order, pick one, write it down, and put a test on the boundary case. Mixed rounding is how a marketplace ends up one cent off on a hundred thousand invoices.

Currency belongs to the order, not to the request. Once you accept a second currency, the exchange rate used at checkout is a fact worth persisting alongside the total — otherwise refunds silently drift against the original payment, and nobody notices until a payout does not reconcile.

Suppliers are a boundary, not a table

The first supplier integration usually gets written directly into the product model, because there is only one supplier and the fields line up. The second one does not line up, and now the product model has fields that only mean something for one partner.

Treat each supplier as an external system behind a thin translation layer that produces your canonical model and nothing else:

  • Keep the raw payload. Store what the supplier actually sent, immutably, next to the normalized result. Every mapping bug is then reproducible without asking the partner to resend a feed.
  • Validate at the edge. A supplier will eventually send "available": "yes" where the contract says boolean. Parse with a schema at the boundary — zod is enough — and reject loudly there instead of discovering it three services later.
  • Version the mapping. When a mapping rule changes, old ingests should still explain themselves. A mappingVersion on the ingest record costs one column.
  • Never let supplier identifiers into your public API. The moment a partner SKU appears in a URL or a webhook payload, changing supplier becomes a breaking change for your consumers.

This is the anti-corruption layer from Domain-Driven Design, and it is one of the few DDD patterns worth applying before you feel the pain, because the alternative is a domain model shaped by whichever partner integrated first.

Draw the seams that internationalization will need

The expensive part of going international is almost never translation. It is that country, currency, tax treatment, and legal entity were assumed to be constants, and now they are parameters threaded through every price-bearing code path.

  • Locale is not a language. de-AT shares a language with de-DE and very little else that matters at checkout: different VAT, different carriers, different consumer law.
  • Make the pair explicit. Every endpoint that returns a price takes a country and a currency, even while both have exactly one possible value. A parameter with one valid option is a one-line change later; an assumption is a migration.
  • Keep the legal entity in the order. Which company sold the item determines invoice layout, tax reporting, and returns address. Deriving it after the fact is guesswork.
  • Do not localize what has no owner. A second language with nobody responsible for keeping it accurate is worse for trust — and for search — than a single well-maintained one.

What is not worth doing in an MVP

Structure is cheap when it is only structure. It gets expensive when it becomes infrastructure. The things I would actively avoid early:

  • Microservices. A modular monolith with enforced module boundaries gives you almost the same freedom to split later, at a fraction of the operational cost. Split when a team or a scaling profile demands it, not in anticipation.
  • A generic rules engine for pricing, shipping, or promotions. Configurability written before the third real rule exists reliably models the wrong axis.
  • Multi-tenancy before the second tenant. Especially in the data model.
  • Your own authentication. There is no upside available here that offsets the downside.
  • Caching before measuring. Early caches mostly hide the query you should have fixed, then desynchronize during the incident that matters.

Orders as facts, money as a typed value, suppliers behind a boundary, and country and currency as parameters. Four decisions, a couple of weeks of extra care at the start, and the MVP gets to keep being the system when volume arrives instead of becoming the thing everyone is trying to replace.

Building a marketplace MVP that survives its own success — Vladyslav Dobrodii