10 min čteníJohnny UnarJohnny Unar

Přestaňte dávat business logiku do Next.js Edge

Edge používejte na rychlá rozhodnutí, stavové operace nechte v Go. Tohle rozdělení řeší bugy v korektnosti, retry logice i observabilitě, které se v SaaS systémech pořád vrací.

rozdělení, které fakt chcete

Next.js 15 vám dává hodně rychlé místo, kde můžete spouštět malé kusy kódu blízko uživatele. To je užitečné, používám to, používáme to i ve steezr, a dál bych to používal na auth gating, výběr locale, bucketing feature flagů, filtrování botů, úpravu cache key a normalizaci requestů. Tohle jsou operace, které jsou pomíjivé, dají se zopakovat bez side effectů, a když selžou, většinou můžete spadnout do bezpečnějšího defaultu. Problém začne ve chvíli, kdy tým uvidí nízkou latenci a rozhodne se, že tam patří i purchase flow, změny subscriptions, přechody stavů dokumentů, odečítání kreditů nebo zápisy z webhooků.

Nepatří.

Edge runtime je špatné místo pro stavovou business logiku, protože přesně ve chvíli, kdy jde do tuhého, začnou být failure modes divnější. Narazíte na částečné retry z klientů, cold starty v podivných regionech, chybějící Node API, užší podporu databázových driverů, nekonzistentní tracing a jemné rozdíly mezi local dev a production, které se ukážou až pod loadem. Pak někdo přidá přímý write do Postgresu z Edge handleru, protože to „ve stagingu fungovalo“, payment provider zopakuje callback, Vercel po regionálním výpadku request přehraje a najednou máte duplicitní záznamy v ledgeru bez rozumné trace, která by ten bordel spojila dohromady.

Pattern se split runtime je jednoduchý. Edge nechte na levná rozhodnutí per request, a každou stavovou command pošlete dál do obyčejného HTTP API v Go 1.22, ideálně nad PostgreSQL a Redisem, pokud potřebujete krátkodobý dedupe nebo rate state. V handoffu předejte identitu, request id, trace headery, tenant context a explicitní idempotency key. Go služba pak vlastní transakce, retry logiku, locking, durable queue i vývoj schématu. Získáte zpátky věci, na kterých začne záležet ve chvíli, kdy se objeví peníze, oprávnění nebo nevratné změny stavu: nejdřív korektnost, pak observabilita, až potom latence.

Tohle pořadí je důležitější, než si lidi chtějí přiznat.

edge je na rozhodování

Dobrá Edge funkce rychle zodpoví jednu otázku a uhne z cesty. Může tenhle uživatel na tuhle route. Jaký experiment bucket má vidět. Má tenhle request skončit v cache variantě podle tenantu a země. Potřebuje tenhle bot challenge. Tohle jsou dost „pure“ operace na to, aby opakované spuštění ničemu nevadilo, a zároveň těží z toho, že běží blízko ingressu requestu.

Špatná Edge funkce otevře transakci, změní dvě tabulky, zavolá Stripe a ještě se pokusí poslat analytics potom, co už začala odcházet response. Tohle míchá pomalé I/O, vícekrokové řešení chyb a side effecty v prostředí, které nikdy nemělo být vaším system of record.

Čistý middleware v Next.js 15 vypadá spíš takhle:

ts
1// middleware.ts
2import { NextRequest, NextResponse } from 'next/server'
3import { jwtVerify } from 'jose'
4
5export const config = {
6 matcher: ['/app/:path*', '/api/:path*'],
7}
8
9const secret = new TextEncoder().encode(process.env.JWT_PUBLIC_KEY_PEM)
10
11export async function middleware(req: NextRequest) {
12 const reqId = req.headers.get('x-request-id') ?? crypto.randomUUID()
13 const traceparent = req.headers.get('traceparent') ?? `00-${crypto.randomUUID().replace(/-/g, '')}-${crypto.randomUUID().replace(/-/g, '').slice(0, 16)}-01`
14
15 const auth = req.headers.get('authorization')
16 let sub = ''
17 let tenantId = ''
18
19 if (auth?.startsWith('Bearer ')) {
20 const token = auth.slice(7)
21 const { payload } = await jwtVerify(token, secret, {
22 algorithms: ['RS256'],
23 issuer: 'https://auth.example.com',
24 audience: 'app',
25 })
26 sub = String(payload.sub ?? '')
27 tenantId = String(payload.tid ?? '')
28 }
29
30 const headers = new Headers(req.headers)
31 headers.set('x-request-id', reqId)
32 headers.set('traceparent', traceparent)
33 if (sub) headers.set('x-auth-sub', sub)
34 if (tenantId) headers.set('x-tenant-id', tenantId)
35
36 return NextResponse.next({ request: { headers } })
37}

Všimněte si, co tam není: žádné write, žádný přímý přístup do databáze, žádná cross-system saga, která si hraje na request handler. Tenhle kód anotuje, validuje a routuje. To je jeho práce. Když Edge handlery udržíte takhle malé, zůstanou čitelné, dobře testovatelné a hlavně bezpečné na spuštění dvakrát.

go vlastní stav

Go 1.22 je místo, kam mají dopadat commandy, protože pro tenhle typ práce dává přesně ty nudné silné stránky, které chcete: stabilní concurrency, jednoduché profiling, předvídatelné chování paměti, standard library, která HTTP zvládá výborně, a prvotřídní podporu pro propagaci contextu a cancellation. Spárujte to s PostgreSQL 18 a máte setup, který přežije retry i chyby operátorů, aniž by se z každé mutace stala legenda předávaná po chodbě.

Mám radši explicitní command endpointy než předstírání, že každá mutace je generický REST. Request na vytvoření faktury, odečtení usage kreditů, schválení refundu nebo finalizaci importu by měl být modelovaný jako command s vlastním idempotency key a vlastní sadou invariantů. Payload validujete ještě před začátkem transakce, command zaregistrujete v idempotency tabulce a pak se transakce z pohledu calleru provede právě jednou.

go
1// POST /v1/commands/consume-credits
2package main
3
4type ConsumeCreditsRequest struct {
5 TenantID string `json:"tenant_id"`
6 UserID string `json:"user_id"`
7 ResourceID string `json:"resource_id"`
8 Amount int64 `json:"amount"`
9 IdempotencyKey string `json:"idempotency_key"`
10}
11
12func (s *Server) ConsumeCredits(w http.ResponseWriter, r *http.Request) {
13 ctx := r.Context()
14 reqID := r.Header.Get("x-request-id")
15 traceparent := r.Header.Get("traceparent")
16
17 var in ConsumeCreditsRequest
18 if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
19 http.Error(w, "invalid json", http.StatusBadRequest)
20 return
21 }
22 if in.Amount <= 0 || in.TenantID == "" || in.IdempotencyKey == "" {
23 http.Error(w, "invalid command", http.StatusUnprocessableEntity)
24 return
25 }
26
27 res, err := s.Commands.ExecuteConsumeCredits(ctx, in, CommandMeta{
28 RequestID: reqID,
29 Traceparent: traceparent,
30 ActorSub: r.Header.Get("x-auth-sub"),
31 TenantID: r.Header.Get("x-tenant-id"),
32 })
33 if err != nil {
34 s.writeCommandError(w, err)
35 return
36 }
37 writeJSON(w, http.StatusAccepted, res)
38}

Uvnitř ExecuteConsumeCredits použijte jednu transakci, účetní řádek zamkněte přes SELECT ... FOR UPDATE, command key zapište do tabulky s unique indexem na (tenant_id, command_type, idempotency_key) a uložte i finální response body, aby opakované volání vracelo stejný tvar výsledku. Je to friendly k migracím, protože command kontrakt zůstává stabilní, i když se schéma pod ním za kvartál dvakrát změní, což je přesně to, co se v rostoucím SaaS děje.

Je to nudná architektura, což je jen jiný způsob, jak říct, že přežije kontakt s reálnými uživateli.

retry bez poškozených dat

Většina týmů říká, že chce retry. Ve skutečnosti má typicky jen duplicitní vykonání. To není totéž a ukáže se to hned při prvním mobilním klientovi, který timeoutne na 2,8 s, zatímco server commitne v 2,9, uživatel klepne znovu a queue processor to mezitím taky zopakuje, protože od upstream proxy viděl 502. Gratuluju, tři zápisy, jeden úmysl.

Řešení začíná tím, že každý request měnící stav budete brát jako idempotentní command. Klient generuje stabilní key pro jeden uživatelský úmysl, ne pro jeden HTTP pokus. Dobrá key vypadá třeba takhle: cmd_01HV7V7J8P6Y3ZK0JY9S2M4D6N, a klient si ji drží přes všechny retry. Edge vrstva nikdy nesmí při retry vytvářet novou key, pokud nemá opravdu trvalý důvod vědět, že jde o nový úmysl.

Na Go straně persistujte přijetí commandu ještě před dokončením side effectu. Třeba takováhle tabulka funguje úplně v pohodě:

sql
1create table command_dedup (
2 tenant_id text not null,
3 command_type text not null,
4 idempotency_key text not null,
5 status text not null,
6 request_hash bytea not null,
7 response_json jsonb,
8 created_at timestamptz not null default now(),
9 primary key (tenant_id, command_type, idempotency_key)
10);

Když se stejná key vrátí s jiným hashem requestu, vraťte 409 Conflict a dejte to hlasitě najevo. Tiché přijetí je přesně způsob, jak se normalizuje poškození dat. Když přijde stejná key se stejným hashem a command už doběhl, vraťte uloženou response. Když je ještě in progress, odpovězte 202 Accepted s polling location nebo payloadem se statusem commandu.

U upstream callů uvnitř commandu musí mít retry budgety a deadliny. Nastavte je explicitně. Třeba payment auth call může dostat jeden retry při connect timeoutu s exponential backoff a jitterem zastropovaným na 250 ms, protože váš p99 budget pro celý request je třeba 1,5 sekundy, ne pozvánka k tomu točit se donekonečna. V Go to obvykle znamená context.WithTimeout, transport s rozumnými idle a handshake timeouty a retry logiku, která se spouští jen u transient tříd chyb, nikdy po write, kde neumíte dokázat, že remote strana nic necommitla.

Na té poslední větě se lidi pálí pořád.

trasujte handoff

Observabilita se rozpadne na hranici runtime, pokud propagaci headerů neberete jako součást kontraktu. Spousta týmů má slušné traces uvnitř Node appky a slušné traces uvnitř Go API, ale mezi nimi žádné propojení spanů, protože někdo zapomněl traceparent, nebo se na každém hopu vygenerovalo nové request id a tím se to celé „vyřešilo“. To je fake observabilita, dashboardy plné barev bez kauzální návaznosti.

Propagujte traceparent, tracestate, pokud ho používáte, x-request-id, autentizovaný subject, tenant id a interní command id, jakmile existuje. Nespoléhejte jen na ad hoc JSON logování. Chcete OpenTelemetry na obou stranách, exportované třeba do Grafana Tempo, Honeycomb nebo Datadog APM, a chcete, aby logy obsahovaly úplně stejná id.

Server-side call z Next.js route handleru do Go může být úplně jednoduchý:

ts
1const res = await fetch(`${process.env.API_URL}/v1/commands/consume-credits`, {
2 method: 'POST',
3 headers: {
4 'content-type': 'application/json',
5 'authorization': req.headers.get('authorization') ?? '',
6 'x-request-id': req.headers.get('x-request-id') ?? crypto.randomUUID(),
7 'traceparent': req.headers.get('traceparent') ?? '',
8 'tracestate': req.headers.get('tracestate') ?? '',
9 'x-auth-sub': req.headers.get('x-auth-sub') ?? '',
10 'x-tenant-id': req.headers.get('x-tenant-id') ?? '',
11 },
12 body: JSON.stringify(command),
13})

V Go pak tyhle hodnoty připojte k request contextu, založte child span a při failu logujte command key i tenant. Jestli někdy budete muset odpovědět na otázku „naúčtovali jsme tomuhle zákazníkovi dvakrát?“, musíte umět projít od ingress requestu přes command record a databázovou transakci až k response od providera bez hádání. Hádat začínají týmy ve chvíli, kdy v pátek večer ručně vrací kredity.

Ještě jedna věc: vracejte chyby, které umí zpracovat stroj. 409 idempotency_key_reused_with_different_payload, 422 insufficient_credits, 503 upstream_timeout_retryable=true. Lidsky čitelný text je fajn, ale důležitější je kód. Klienti z něj umí postavit správné retry chování. Z vágního pocitu to nepostaví.

migrace přestanou být strašák

Nejpříjemnější side effect tohohle rozdělení je, že změny schématu přestanou prosakovat až na request edge. Když váš Edge kód hlavně čte claims, nastavuje headery a vybírá execution path, můžete tabulky a write modely měnit v Go službě, aniž byste při každé změně billing pravidla sahali do globálně distribuovaného runtime.

Command kontrakty vám dávají buffer. Přidejte nullable sloupec, nasaďte kód, který zapisuje starou i novou reprezentaci, udělejte backfill na pozadí, přepněte reads a staré cesty smažte později. Standardní expand-contract práce. Rozdíl je v tom, že vaše veřejná mutační plocha zůstává stabilní, protože klienti posílají commandy, ne blob ve tvaru tabulky, který zrovna odpovídá aktuální náladě vašeho ORM.

Viděl jsem tohle hodněkrát u týmů, které shipují rychle. Django monolit obroste pár Next.js frontendů, někdo přidá Edge handlery, protože auth check už tam stejně byl, a za půl roku ty handlery mluví přímo se třemi systémy a nikdo se nechce dotknout migrace, protože není vidět blast radius. Když runtime oddělíte včas, téhle pasti se vyhnete. Globálně distribuovanou vrstvu nechte bez stavu, stavová pravidla držte v jedné službě nad jedním transakčním store a každou mutaci navrhněte tak, aby byla replay-safe.

To neznamená jeden obří Go servis, který spolkne celý stack. Znamená to jedno jasné místo, kde žije korektnost. Pořád můžete stavět spoustu produktových ploch v Next.js, HTMX, React Native, Django nebo čemkoli, co dává smysl, a není na tom nic dogmatického. Ten názor je užší. Stavová business logika má žít tam, kde jsou transakce, tracing, retry a migrace first-class concern. Edge runtime tímhle testem neprojde.

Jestli už dnes máte v Edge kód, který hýbe penězi, začal bych u nejcennějších commandů: změny subscriptions, mutace zůstatků, finalizace importů, admin akce. Přetáhněte je za idempotentní Go API, zachovejte headery, přidejte skutečné command records a sledujte, kolik bugů ve stylu „nejde to reprodukovat“ se najednou změní na obyčejné, debuggovatelné inženýrství.

Johnny Unar

Napsal/a

Johnny Unar

Chcete s námi spolupracovat?

Edge používejte na rychlá rozhodnutí, stavové operace nechte v Go. Tohle rozdělení řeší bugy v korektnosti, retry logice i observabilitě, které se v SaaS systémech pořád vrací.