Modestly
one checkout that pays many brands

Clean Architecture in C#, load-tested to 2,180 concurrent shoppers, paying brands through Stripe Connect

6,054 commits.

Founder, sole engineerC# · ASP.NET Core · PostgreSQLDec 2025 to now

Try it

The marketplace isn’t open to shoppers yet, so this walkthrough uses real screens with placeholder brands and products: the shop and the brand portal, each on a desktop and on a phone.

The shop opens on one promise: independent modest-fashion labels, one bag, one payment.

The shop opens on one promise: independent modest-fashion labels, one bag, one payment.

Use the arrow keys or swipe

Architecture

Web shop, brand portal, admin (Next.js)
Phone app (React Native)
API (.NET, four layers)
PostgreSQL: orders, ledger, payout rules
Background jobs: checkout, money, store sync
Stripe Connect: one account per brand
Brands' Shopify and WooCommerce shops
Email and carrier tracking

Connections

  • Web shop, brand portal, admin (Next.js) → API (.NET, four layers): browse, bag, checkout
  • Phone app (React Native) → API (.NET, four layers): same API
  • API (.NET, four layers) → PostgreSQL: orders, ledger, payout rules: one statement per step
  • Background jobs: checkout, money, store sync → PostgreSQL: orders, ledger, payout rules: claim work, write results
  • Background jobs: checkout, money, store sync → Stripe Connect: one account per brand: authorise, capture, pay out
  • Background jobs: checkout, money, store sync → Brands' Shopify and WooCommerce shops: import products, push stock
  • API (.NET, four layers) → Email and carrier tracking: order and payout mail

The web shop, brand portal and admin are one Next.js application. The phone app is React Native. Both talk to one .NET API.

The API is split into four projects: Domain, Application, Infrastructure and Api. Dependencies only point inwards. A test in the deploy gate reads both the project files and the compiled assemblies and fails the build if Domain references anything, or if any other provider’s wire format (payment, VAT lookup, shop JSON) leaks into Domain.

PostgreSQL holds the orders, a money ledger and the payout rules. The money logic lives in database functions, so the rules that decide what a brand is owed run inside the same transaction that reads the ledger.

Background jobs do the slow and risky work: the checkout job talks to the payment provider, the money job reads each brand’s balance and pays out, and the store-sync job imports products from brands’ shops and pushes stock changes back. Every job claims its work in the database first, so a crash or a restart never leaves a half-done step without a record.

Every push to the main branch runs a deploy gate on the server: secret scan, tests, image build, database migration and an HTTPS probe. A commit that fails any step never reaches the site.

One checkout, many brands, each paid on its own account

Constraint. Each brand is its own merchant with its own payment account. A shopper pays once. If two brands are in the bag and only one charge succeeds, the shopper has paid for half an order.

Options. Charge everything to the platform and transfer to brands later, or charge each brand’s account directly.

Decision. Charge each brand directly, in two stages. The card is saved once, copied to every brand’s account, and each brand’s charge is only authorised. The order is captured only when every brand has authorised, and placed only when every brand has captured. No database lock is held across a call to the payment provider. An answer that gets lost in transit is recorded as unknown and asked again under the same idempotency key, never guessed.

Result. There is no state where a shopper is charged by some brands for an order that never existed. A refund is only ever issued for a charge the provider confirmed.

Paying out without knowing what a payout contains

Constraint. The payment provider does not say which orders a manual payout contains. Refunds, disputes and fees keep arriving after the sale.

Options. Reconstruct payouts after the fact from the provider’s reports, or decide the contents first and record them before any money moves.

Decision. A database function writes the payout’s lines before the provider is called, and the payout row’s id becomes the idempotency key. A brand is never paid on a ledger that was not read to the end in the same run. The split terms are stored on each order when its share is booked, so changing a brand’s terms can never reach back into money already promised.

Result. Every payout can be explained line by line, and a retry can never pay twice.

Writing stock into shops we don’t control

Constraint. When something sells on Modestly, the brand’s own shop must lose that stock too. Shopify and WooCommerce handle retries differently, and a timeout tells you nothing about whether the write happened.

Decision. Writes go through a queue, one row at a time, at most a few per shop per run. What each shop needs to make a retry safe is stored before the request leaves: an idempotency key for Shopify, the expected before and target values for WooCommerce. An uncertain answer is marked unknown, and the next try reads before it writes. One rule table decides what every shop answer means, for every job and both platforms.

Result. A sale on Modestly cannot oversell in the brand’s shop through a retry, and a mass refusal across many shops in one run is treated as our bug, not as every brand’s broken keys.

From 450 to 2,180 concurrent shoppers on one small server

Constraint. A load test failed at about 450 concurrent users, and an earlier one ran the shared reverse proxy out of memory. Launch was weeks away, and the budget said no second server and no paid CPU.

Options. Buy more machine, or stop rendering the same page thousands of times.

Decision. Let Cloudflare’s edge serve the shared shop pages: the front page, brand pages, product pages and the shop listings. The free plan keys a cached copy on the address alone, but the page a visitor gets depends on whether they are past the launch wall, their currency and their country. So a transform rule writes all four into one variant key in the address before the cache is asked, and the server re-renders whenever that key disagrees with what it reads itself, so nothing is ever stored under someone else’s key. Only requests that arrived with no query string of their own are cached, so nobody can name a key they do not hold. Pages live 30 seconds at the edge, the browser keeps nothing, and a takedown is bounded to about two minutes without giving the server a Cloudflare key.

Result. A k6 ramp to 3,017 virtual users: p95 under one second up to about 970 concurrent shoppers, and at most 0.1% errors up to about 2,180, on the same virtual machine and with no paid cores.

Code

How a brand’s order splits into an early part and a held part. Lightly edited for display: planning references removed from comments, one query shortened with -- ….

api/Modestly.Infrastructure/migrations/0307_money_terms.sql

  -- The early part is used up by every row of part 'early': what was paid, what came back, and the fees a payout took
  -- out of it, so a payout's fee never reads as early money still due.
  SELECT coalesce(-sum(e.amount_minor) FILTER (WHERE e.kind IN ('paid', 'paid_back')), 0),
    coalesce(-sum(e.amount_minor) FILTER (WHERE e.kind IN ('paid', 'paid_back') AND p.state IN ('sending', 'unknown', 'accepted')), 0),
    coalesce(-sum(e.amount_minor) FILTER (WHERE e.part = 'early'), 0)
  INTO v_paid, v_way, v_used
  FROM money_entry e LEFT JOIN payout p ON p.id = e.payout_id
  WHERE e.brand_order_id = bo.id AND e.account <> 'platform';

  -- A refund on its way has not reached the ledger yet and must not be paid out from under it.
  v_flight := (SELECT ceil(in_flight * coalesce(bo.share_rate, 1))::bigint FROM refunded_of(bo.id));
  -- …

  v_free := greatest(v_owed - v_frozen - v_flight, 0);
  IF bo.share_minor IS NOT NULL AND NOT v_unbooked THEN
    IF p_now >= v_due THEN
      v_early := least(greatest(bo.early_minor - v_used, 0), v_free);
    END IF;
    IF v_closed THEN
      v_held := v_free - v_early;
    END IF;
  END IF;

One rule for what a shop’s answer means. Lightly edited for display: planning references removed from comments.

api/Modestly.Domain/StoreMap.cs

    public enum Act { Go, Again, Ours, Shops }

    // What a job does with a shop's answer: one rule for the imports and store-sync jobs and both shops. A timeout or
    // a busy shop says nothing about it and is asked again. "modestly" is ours, never the shop's failure. Every other
    // word is the shop's own. No default: a word a map gains throws in both jobs alike until it is given its action here.
    public static Act ActOn(string outcome) => outcome switch
    {
        "success" => Act.Go,
        "timeout" or "throttled" => Act.Again,
        "modestly" => Act.Ours,
        "shop_failure" or "refused" or "reauth" => Act.Shops,
        _ => throw new ArgumentOutOfRangeException(nameof(outcome), outcome, "a shop answer with no action"),
    };

    // Most Shopify stores refusing their keys in one run is our bug or Shopify's incident, never every brand's keys.
    public static bool MassRefusal(int refused, int asked) => refused >= 2 && refused * 2 > asked;

A payment answer becomes a charge state, and only a card’s own failure counts against the shopper. Lightly edited for display: planning references removed from comments.

api/Modestly.Domain/Payment.cs

    public static (ChargeState State, bool Reconfirm, FailReason? Failure) State(string? status, string? errorCode, string? errorType) =>
        status switch
        {
            "succeeded" => (ChargeState.Captured, false, null),
            "requires_capture" => (ChargeState.Authorised, false, null),
            "requires_action" => (ChargeState.NeedsAuthentication, false, null),
            "processing" or "requires_confirmation" => (ChargeState.Authorising, false, null),
            "canceled" => (ChargeState.Cancelled, false, null),
            "requires_payment_method" => (errorCode, errorType) switch
            {
                ("authentication_required", _) => (ChargeState.NeedsAuthentication, true, null),
                ("payment_intent_authentication_failure", _) => (ChargeState.Failed, false, FailReason.Authentication),
                (_, "card_error") => (ChargeState.Failed, false, FailReason.Card),
                // Nothing the card did: never counted against the shopper.
                _ => (ChargeState.Failed, false, FailReason.Brand),
            },
            _ => throw new FormatException("payment intent status " + status),
        };

Background

Independent modest-fashion brands in Europe sell from their own small web shops. Each one has to find its own customers, and a shopper who wants to compare ten brands has to check out ten times. A shared marketplace solves that, but only if it can take one payment from a shopper, pay many brands correctly, hold back enough to cover returns, and keep every brand’s stock honest while the brand keeps selling in its own shop.

Modestly is a curated EU marketplace where one checkout pays many independent brands. It has a web shop, a brand portal, an admin and a phone app, all on one backend. Brands connect their own payment account, import their catalogue from Shopify or WooCommerce, and get paid out on fixed terms: by default 70% as soon as the money is available and the remaining 30% once the return window has closed.

The marketplace is pre-launch. Brands are being onboarded behind a wall before shoppers are let in, so there are no public orders yet. I was the sole engineer: I set the architecture, wrote the specifications, directed the implementation and reviewed every change.

What I’d do differently

  • Set a load target before building the shop, not after. The edge cache took the shop from about 450 to 2,180 concurrent shoppers, but it came weeks before launch. With a target in week one, the cache rules would have been designed in instead of fitted around a finished app.
  • Write fewer, larger migrations before launch. The schema moved fast while the money rules were being settled. Before real orders arrive, I would fold the history into one baseline so the next engineer reads the current shape, not its path.
  • Keep the money rules in one language. Putting them in database functions gave me atomic reads of the ledger, but it splits the domain between SQL and C#. With hindsight I would keep the rules in C# and use the database only for the locks and the ledger.

Deep dives

In the order the system was hard to build.