Modestly
ét checkout der betaler mange mærker
Clean Architecture i C#, belastningstestet til 2.180 samtidige kunder, betaler mærker gennem Stripe Connect
6.054 commits.
- 269,608linjer kode
- 2,180samtidige kunder, ≤0,1 % fejl
- 4,503tests i deploy-gaten
- 409API-endpoints
- 6,054commits
Prøv det
Markedspladsen er ikke åben for kunder endnu, så denne gennemgang bruger de rigtige skærme med fiktive brands og varer: shoppen og brandportalen, begge på computer og på mobil.

Butikken åbner med ét løfte: uafhængige mærker inden for modest fashion, én kurv og én betaling.
Brug piletasterne eller swipe
Arkitektur
Forbindelser
- Webshop, brandportal, admin (Next.js) → API (.NET, fire lag): browse, kurv, checkout
- App (React Native) → API (.NET, fire lag): samme API
- API (.NET, fire lag) → PostgreSQL: ordrer, regnskab, udbetalingsregler: ét statement pr. trin
- Baggrundsjobs: checkout, penge, lagersync → PostgreSQL: ordrer, regnskab, udbetalingsregler: tager opgaver, skriver resultater
- Baggrundsjobs: checkout, penge, lagersync → Stripe Connect: én konto pr. brand: autoriserer, hæver, udbetaler
- Baggrundsjobs: checkout, penge, lagersync → Brandenes Shopify- og WooCommerce-butikker: henter varer, sender lager
- API (.NET, fire lag) → Mail og forsendelsessporing: ordre- og udbetalingsmails
Webshop, brandportal og admin er én Next.js-applikation. Appen er React Native. Begge taler med ét .NET API.
API’et er delt i fire projekter: Domain, Application, Infrastructure og Api, og afhængighederne peger kun indad. En test i deploy-gaten læser både projektfilerne og de kompilerede assemblies og stopper buildet, hvis Domain refererer til noget, eller hvis en udbyders dataformat (betaling, momsopslag, butikkernes JSON) sniger sig ind i Domain.
PostgreSQL rummer ordrerne, et pengeregnskab og udbetalingsreglerne. Pengelogikken ligger i databasefunktioner, så reglerne for, hvad et brand har til gode, kører i samme transaktion, som læser regnskabet.
Baggrundsjobs tager det langsomme og risikable arbejde: checkout-jobbet taler med betalingsudbyderen, pengejobbet læser hvert brands saldo og udbetaler, og lagersync-jobbet henter varer fra brandenes butikker og sender lagerændringer tilbage. Hvert job tager sin opgave i databasen først, så et nedbrud eller en genstart aldrig efterlader et halvt trin uden spor.
Hvert push til main kører en deploy-gate på serveren: secret-scan, tests, image-build, databasemigrering og et HTTPS-tjek. Et commit, der fejler et trin, når aldrig sitet.
Ét checkout, mange brands, hver betalt på egen konto
Begrænsning. Hvert brand er sin egen forretning med sin egen betalingskonto. Kunden betaler én gang. Er der to brands i kurven, og lykkes kun den ene betaling, har kunden betalt for en halv ordre.
Muligheder. Hæv alt på platformen og overfør til brandene bagefter, eller hæv direkte på hvert brands konto.
Beslutning. Direkte på hvert brand, i to trin. Kortet gemmes én gang, kopieres til hvert brands konto, og hver betaling bliver kun autoriseret. Ordren hæves først, når alle brands har autoriseret, og placeres først, når alle har hævet. Ingen databaselås holdes hen over et kald til betalingsudbyderen. Et svar, der forsvinder undervejs, gemmes som ukendt og spørges igen med samme idempotency key. Det bliver aldrig gættet.
Resultat. Der findes ingen tilstand, hvor nogle brands har hævet penge for en ordre, der aldrig blev til. Der refunderes kun betalinger, som udbyderen har bekræftet.
Udbetaling uden at vide, hvad udbetalingen indeholder
Begrænsning. Betalingsudbyderen fortæller ikke, hvilke ordrer en manuel udbetaling dækker. Refunderinger, indsigelser og gebyrer kommer stadig ind efter salget.
Muligheder. Gendan udbetalingerne bagefter fra udbyderens rapporter, eller bestem indholdet først og gem det, før pengene flytter sig.
Beslutning. En databasefunktion skriver udbetalingens linjer, før udbyderen bliver kaldt, og rækkens id bliver idempotency key. Et brand bliver aldrig betalt på et regnskab, der ikke er læst til ende i samme kørsel. Fordelingen gemmes på ordren, når andelen bogføres, så nye vilkår aldrig rækker tilbage i penge, der allerede er lovet væk.
Resultat. Hver udbetaling kan forklares linje for linje, og et nyt forsøg kan aldrig betale to gange.
Lager skrevet ind i butikker, vi ikke styrer
Begrænsning. Sælges en vare på Modestly, skal brandets egen butik også miste den på lager. Shopify og WooCommerce håndterer gentagelser forskelligt, og en timeout siger intet om, hvorvidt skrivningen skete.
Beslutning. Skrivninger går gennem en kø, én række ad gangen og kun nogle få pr. butik pr. kørsel. Det, hver butik skal bruge for at gøre et nyt forsøg sikkert, gemmes, før kaldet sendes: en idempotency key til Shopify, forventet før- og målværdi til WooCommerce. Et usikkert svar markeres som ukendt, og næste forsøg læser, før det skriver. Én regeltabel bestemmer, hvad hvert svar betyder, for alle jobs og begge platforme.
Resultat. Et salg på Modestly kan ikke oversælge i brandets butik på grund af et nyt forsøg, og hvis mange butikker afviser i samme kørsel, behandles det som vores fejl og ikke som alle brandenes nøgler.
Fra 450 til 2.180 samtidige kunder på én lille server
Begrænsning. En load test fejlede ved cirka 450 samtidige brugere, og en tidligere løb den fælles reverse proxy tør for hukommelse. Lanceringen var uger væk, og budgettet sagde ingen ekstra server og ingen betalt CPU.
Muligheder. Købe mere maskine, eller holde op med at rendere den samme side tusindvis af gange.
Beslutning. Lad Cloudflares edge servere de fælles butikssider: forsiden, mærkesider, produktsider og butikkens lister. Gratisplanen nøgler en cachet kopi alene på adressen, men den side, en besøgende får, afhænger af, om de er forbi lanceringsvæggen, af valuta og af land. Derfor skriver en transform-regel alle fire ind i én variantnøgle i adressen, før cachen spørges, og serveren renderer igen, hver gang nøglen er uenig med det, den selv læser, så intet nogensinde gemmes under en andens nøgle. Kun forespørgsler uden egen query cachelagres, så ingen kan navngive en nøgle, de ikke selv har. Sider lever 30 sekunder på edge, browseren gemmer intet, og en nedtagning er begrænset til omkring to minutter, uden at serveren får en Cloudflare-nøgle.
Resultat. En k6-rampe til 3.017 virtuelle brugere: p95 under ét sekund op til cirka 970 samtidige kunder og højst 0,1 % fejl op til cirka 2.180, på den samme virtuelle maskine og uden betalte kerner.
Kode
Sådan deles et brands ordre i en tidlig del og en tilbageholdt del. Let redigeret til visning: planlægningsreferencer fjernet fra kommentarer, én forespørgsel forkortet med -- ….
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;Én regel for, hvad en butiks svar betyder. Let redigeret til visning: planlægningsreferencer fjernet fra kommentarer.
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;Et betalingssvar bliver til en tilstand, og kun kortets egen fejl tæller mod kunden. Let redigeret til visning: planlægningsreferencer fjernet fra kommentarer.
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),
};Baggrund
Uafhængige modest fashion-brands i Europa sælger fra deres egne små webshops. Hver især skal de selv finde kunderne, og en kunde, der vil sammenligne ti brands, skal igennem ti checkouts. En fælles markedsplads løser det, men kun hvis den kan tage én betaling fra kunden, betale mange brands korrekt, holde nok tilbage til returvarer og holde hvert brands lager retvisende, mens brandet fortsat sælger i sin egen butik.
Modestly er en kurateret EU-markedsplads, hvor ét checkout betaler mange uafhængige brands. Der er webshop, brandportal, admin og app, alt sammen på én backend. Brandene forbinder deres egen betalingskonto, henter kataloget fra Shopify eller WooCommerce og får udbetalt efter faste vilkår: som standard 70 %, så snart pengene er tilgængelige, og de sidste 30 %, når returfristen er udløbet.
Markedspladsen er ikke lanceret endnu. Brands bliver taget ind bag en mur, før kunderne får adgang, så der er ingen offentlige ordrer endnu. Jeg var eneste udvikler: Jeg lagde arkitekturen, skrev specifikationerne, styrede implementeringen og gennemgik hver ændring.
Det ville jeg gøre anderledes
- Sætte et belastningsmål, før shoppen blev bygget. Edge-cachen tog shoppen fra cirka 450 til 2.180 samtidige kunder, men den kom uger før lanceringen. Med et mål i uge ét var cache-reglerne blevet designet ind fra start i stedet for tilpasset en færdig app.
- Færre og større migreringer før lancering. Skemaet flyttede sig hurtigt, mens pengereglerne faldt på plads. Før de første rigtige ordrer ville jeg samle historikken i én baseline, så den næste udvikler læser den nuværende form og ikke vejen dertil.
- Pengereglerne i ét sprog. Databasefunktioner gav atomare læsninger af regnskabet, men domænet blev delt mellem SQL og C#. I dag ville jeg holde reglerne i C# og kun bruge databasen til låse og regnskab.
Dybdegående
I den rækkefølge systemet var svært at bygge.