To pierwsza część serii Backend dla frontendowca, w której zaczynamy od fundamentów, czyli architektury aplikacji webowej, serwera aplikacji, baz danych, kontraktu i . To są tematy, które frontendowiec najczęściej dotyka jako pierwszy, nawet jeśli formalnie nie pisze backendu na co dzień.
Nie musisz od razu stawać się backendowcem, ale warto jednak znać język, którym posługuje się druga strona zespołu. Dzięki temu szybciej zrozumiesz, dlaczego formularz zwraca 422, czemu lista ładuje się pięć sekund, skąd biorą się problemy z CORS-em i dlaczego tylko jeden endpoint API czasem oznacza kilka dni pracy.
Backend dla frontendowca w architekturze aplikacji webowej
Zanim wejdziemy w szczegóły, warto zobaczyć cały obraz. Dla użytkownika aplikacja jest jednym ekranem w przeglądarce, ale już dla systemu to zestaw współpracujących warstw: frontend wysyła żądanie, load balancer kieruje je do jednego z serwerów API, serwer rozmawia z bazą, pamięcią podręczną i magazynem plików, a potem wszystko wraca do jako odpowiedź.
To oczywiście duże uproszczenie, ale bardzo przydatne w praktyce, ponieważ większość problemów odbieranych przez frontend jako wolno działający ekran lub dziwny błąd ma swoje źródło właśnie na styku tych wymienionych wyżej warstw.
1. Serwer aplikacji jako serce backendu
W serwerze aplikacji sprawdzasz, czy użytkownik może wykonać akcję, walidujesz dane, zapisujesz coś w bazie i decydujesz, jaką odpowiedź powinien dostać frontend.
- nasłuchuje na porcie, na przykład 3000,
- przyjmuje i ogranicza rozmiar żądań,
- uwierzytelnia użytkownika i sprawdza uprawnienia,
- waliduje dane oraz wykonuje logikę biznesową,
- zarządza limitami czasu i anulowaniem pracy,
- zwraca odpowiedź w stabilnym formacie.
Popularne opcje serwera aplikacji
Połączenie Node.js oraz Express stanowi najczęstszy punkt wejścia dla osób wywodzących się z frontendu, ponieważ pozwala pozostać przy znajomym języku JavaScript, jednocześnie przenosząc istotną część logiki aplikacji na stronę serwera:
Inne popularne opcje w JS i TS:
Fastify oferuje walidację opartą na JSON Schema i architekturę pluginów.
Hono działa w wielu środowiskach uruchomieniowych, między innymi w środowiskach edge, Node.js, Deno i Bun.
NestJS narzuca modułową strukturę aplikacji i dobrze pasuje do zespołów, które potrzebują wspólnych konwencji.
Bun jest środowiskiem uruchomieniowym, a nie frameworkiem. Oferuje własny zestaw narzędzi i część zgodności z API Node.js.
Deno jest środowiskiem uruchomieniowym z modelem uprawnień. Dostęp do sieci, systemu plików i zmiennych środowiskowych można jawnie ograniczać.
Pozostałe języki:
- Python: Django, FastAPI, Flask,
- Ruby: Ruby on Rails,
- Go: Gin, Echo,
- Java: Spring Boot,
- PHP: Laravel,
- .NET: ASP.NET Core.
Co robi serwer aplikacji w backendzie?
W najprostszej wersji serwer aplikacji jest tylko routerem i warstwą pośrednią między frontendem a bazą. W realnych projektach bardzo szybko dochodzą kolejne odpowiedzialności:
- Routing mapuje metodę i URL na kod.
- Middleware obsługuje przekrojowe reguły, między innymi uwierzytelnianie, logowanie i CORS.
- Warstwa aplikacyjna wykonuje przypadki użycia.
- Logika domenowa pilnuje reguł biznesowych.
- Warstwa danych wykonuje zapytania i transakcje.
- Warstwa HTTP formatuje odpowiedzi i statusy.
2. Bazy danych w backendzie aplikacji webowej
Baza danych jest tym elementem backendu, którego najłatwiej nie docenić na początku. Dopóki masz kilka rekordów, wszystko jest proste. Problemy zaczynają się, gdy dochodzą relacje, filtry, indeksy, migracje, transakcje i rosnąca liczba użytkowników.
Bazy SQL, czyli relacyjne bazy danych
Dane w tabelach z relacjami:
Kiedy : e-commerce, finanse, CRM, panele administracyjne i większość klasycznych aplikacji biznesowych. Jeżeli dane mają relacje i potrzebujesz spójności, SQL jest bezpiecznym domyślnym wyborem.
Popularne: PostgreSQL, MySQL, SQLite
Bazy NoSQL, czyli nierelacyjne bazy danych
Dokumenty przypominające JSON:
Kiedy wybrać NoSQL: w sytuacjach, gdy doskonale znasz dominujące wzorce odczytu oraz zapisu, a model wybranej bazy dokumentowej lub klucz-wartość idealnie do nich pasuje. Warto pamiętać, że obsługa mediów społecznościowych, działanie w czasie rzeczywistym czy obsługa dużej skali same w sobie nie stanowią automatycznych argumentów przemawiających za NoSQL, ponieważ PostgreSQL równie dobrze radzi sobie z obsługą danych w formacie JSON, pełnymi transakcjami oraz ogromnymi zbiorami danych. Ostateczna decyzja powinna wynikać z analizy relacji między danymi, rygorystycznych wymagań dotyczących spójności, specyfiki planowanych zapytań oraz wybranego sposobu skalowania systemu.
Popularne: MongoDB, Cloud Firestore, Firebase Realtime Database, DynamoDB
ORM jako abstrakcja nad bazą danych
ORM albo query builder pozwala pisać zapytania w kodzie aplikacji, zamiast składać SQL ręcznie w każdym miejscu. To wygodne, bo dostajesz typowanie, migracje i mniej powtarzalnego kodu szablonowego. Nadal warto jednak rozumieć SQL, bo ORM nie zwalnia z myślenia o indeksach, JOIN-ach i kosztach zapytań.
Popularne ORM-y i query buildery: Prisma, Drizzle, Kysely, TypeORM (Node), SQLAlchemy (Python), Hibernate (Java), Eloquent (Laravel), Active Record (Rails).
Indeksy i szybkie wyszukiwanie w bazie
Indeks działa trochę jak spis treści w książce. Bez niego baza może przejrzeć wiele wierszy, żeby znaleźć właściwy rekord. Przy selektywnym warunku i odpowiednim indeksie planer zapytań może przejść do znacznie mniejszego zakresu danych. Dla unikalnego adresu e-mail warto utworzyć indeks, który jednocześnie egzekwuje regułę biznesową.
Kilka zasad wystarczy na start:
Indeks projektuj pod konkretne zapytanie, jego selektywność,
WHERE,JOINiORDER BY.Kolejność kolumn w indeksie złożonym ma znaczenie. B-tree na
(user_id, created_at)najskuteczniej ogranicza skan, gdy zapytanie filtruje po wiodącymuser_id.Indeks unikalny jednocześnie przyspiesza wyszukiwanie i egzekwuje brak duplikatów. Nie twórz obok niego drugiego indeksu na ten sam zestaw kolumn bez wyraźnego powodu.
Każdy indeks zajmuje miejsce i zwiększa koszt zapisu. Nie indeksuj na zapas.
Zacznij od
EXPLAIN, aEXPLAIN ANALYZEuruchamiaj świadomie, ponieważ faktycznie wykonuje zapytanie.
Ograniczenia bazy jako ostatnia linia obrony
Walidacja w Zod lub formularzu poprawia komunikaty, ale nie chroni przed dwoma żądaniami wykonywanymi równocześnie. Reguły integralności, które zawsze muszą obowiązywać, zapisuj również w schemacie bazy.
PRIMARY KEY, UNIQUE, FOREIGN KEY, NOT NULL i CHECK chronią dane niezależnie od tego, czy zapis pochodzi z API, zadania w tle, migracji czy konsoli administracyjnej. Backend powinien przechwycić błąd ograniczenia i zamienić go na stabilny błąd domenowy, na przykład 409 Conflict.
Transakcje w bazie danych: wszystko albo nic
Transakcja jest potrzebna wtedy, gdy kilka operacji musi wydarzyć się jako jedna całość. Najprostszy przykład to przelew: -100 z konta A i +100 na konto B. Jeżeli druga operacja się nie powiedzie, wtedy pierwsza nie może zostać w bazie.
Jeśli druga operacja rzuci błąd przed zatwierdzeniem transakcji, pierwsza zostanie cofnięta przez ROLLBACK. Przekroczenie limitu czasu żądania w przeglądarce nie gwarantuje jednak anulowania pracy w bazie. Serwer musi poprawnie propagować anulowanie, ustawić limity czasu transakcji i ustalić wynik operacji przed bezpiecznym ponowieniem.
ACID opisuje fundamentalne właściwości transakcji w relacyjnej bazie. Są to atomowość, spójność, izolacja i trwałość.
Sama transakcja nie rozwiązuje każdego wyścigu. Dwa równoległe przelewy mogą odczytać ten sam stan konta. W zależności od reguły potrzebujesz warunkowego UPDATE, blokady wiersza przez SELECT ... FOR UPDATE, ograniczenia CHECK albo wyższego poziomu izolacji z obsługą ponowienia transakcji.
Problem N+1, który szybko odczuwa frontend
Problem N+1 jest świetnym przykładem błędu backendowego, który frontend odczuwa bardzo wyraźnie. Kod pozornie wygląda niewinnie: pobierasz listę, a potem dla każdego elementu dociągasz autora. Przy małej liczbie rekordów jakoś działa, ale przy większej liczbie zaczyna zabijać czas odpowiedzi.
Dla 100 postów pierwsza wersja wykonuje 101 zapytań. Wersja z relacją ogranicza tę liczbę, ale nie musi generować dokładnie jednego JOIN. ORM może użyć jednego zapytania albo kilku zapytań grupowych zależnie od konfiguracji i strategii ładowania relacji. Sprawdzaj rzeczywisty SQL i liczbę zapytań, zamiast zakładać zachowanie na podstawie samego kodu ORM.
Pula połączeń z bazą danych
Otwarcie nowego połączenia do bazy wymaga połączenia sieciowego, negocjacji protokołu i uwierzytelnienia. Pula utrzymuje ograniczoną liczbę gotowych połączeń współdzielonych między żądaniami.
W środowiskach : platforma może uruchomić wiele instancji równolegle, a każda z nich otwiera własną pulę połączeń, przez co ich suma łatwo przekracza limit bazy danych. Aby tego uniknąć, warto skorzystać z poolera dostawcy lub PgBouncera, odpowiednio ograniczyć rozmiar puli dla pojedynczej instancji oraz na bieżąco monitorować liczbę aktywnych i oczekujących połączeń. W środowiskach krótkotrwałych dobrze sprawdza się również sterownik zaprojektowany specjalnie z myślą o architekturze serverless.
Migracje i wersjonowanie schematu bazy danych
Schemat bazy też jest częścią aplikacji. Dodanie kolumny, zmiana typu albo usunięcie tabeli nie powinny być ręczną akcją „klikniętą na produkcji”. Migracje zapisują te zmiany jako kod, który trafia do repozytorium i może zostać wykonany w tej samej kolejności na każdym środowisku.
Każde środowisko wykonuje te same migracje w tej samej kolejności. prisma migrate dev służy do pracy lokalnej. Na produkcji użyj procesu wdrożeniowego, na przykład prisma migrate deploy, i nie generuj migracji podczas startu każdej instancji.
Przy wdrożeniach bez przestoju stosuj schemat expand and contract. Najpierw dodaj kompatybilne pole lub tabelę, potem wdroż kod obsługujący stary i nowy schemat, uzupełnij historyczne dane, przełącz odczyty, a dopiero w osobnym wdrożeniu usuń stary element. Duże migracje testuj na danych zbliżonych rozmiarem do produkcji i obserwuj blokady.
3. API jako umowa między frontendem a backendem
API nie jest tylko technicznym endpointem, ponieważ to kontrakt między zespołami i warstwami aplikacji. Jeśli kontrakt jest niejasny, frontend zaczyna zgadywać: czy pusta tablica oznacza brak danych, czy błąd? Czy 400 to walidacja, czy zepsuty JSON? Czy żądanie POST można ponowić? Dobre API usuwa takie pytania.
REST API
REST to najczęściej spotykane podejście, bo dobrze mapuje się na HTTP: zasoby mają adresy, a akcje są opisane metodami.
PUT opisuje utworzenie lub pełne zastąpienie stanu zasobu pod znanym URI i jest idempotentny. Częściową zmianę kilku pól zwykle lepiej wyrazić przez PATCH. Nazwa endpointu API nie wystarczy. Backend i dokumentacja muszą zachować semantykę metody.
GraphQL
GraphQL przesuwa sporą część kontroli bezpośrednio w ręce klienta, dzięki czemu interfejs nie otrzymuje odgórnie narzuconej, sztywnej struktury odpowiedzi, lecz może precyzyjnie zadeklarować dokładnie takie pola, jakie są mu w danym momencie potrzebne:
To jest bardzo wygodne przy produktach, w których różne widoki potrzebują różnych kształtów danych. Dzieje się to za cenę większej złożoności po stronie serwera, pamięci podręcznej i obserwowalności. Produkcyjne GraphQL wymaga limitu głębokości lub kosztu zapytania, limitów batchingu i paginacji list. Resolver pól może też stworzyć N+1, dlatego często stosuje się batching w stylu DataLoader.
tRPC
tRPC jest najbardziej atrakcyjne wtedy, gdy cały stos technologiczny jest w TypeScripcie. Zamiast ręcznie synchronizować typy żądań i odpowiedzi, frontend korzysta z typów wyprowadzonych bezpośrednio z backendu.
Statusy HTTP, które powinien rozumieć frontend
Status HTTP stanowi pierwszą informację, jaką interfejs otrzymuje z API, więc traktowanie każdego kodu różnego od 200 w ten sam sposób oznacza utratę cennego kontekstu. Kody 401, 403, 422 oraz 429 wymagają zupełnie innych reakcji w warstwie wizualnej aplikacji.
| Kod | Znaczenie | Typowa reakcja klienta |
|---|---|---|
| 200 OK | Sukces z reprezentacją | Odczytaj body |
| 201 Created | Utworzono zasób | Odczytaj body i Location, jeśli jest dostępny |
| 202 Accepted | Przyjęto pracę asynchroniczną | Pokaż oczekiwanie i odpytuj zasób operacji |
| 204 No Content | Sukces bez body | Nie próbuj parsować JSON |
| 304 Not Modified | Reprezentacja w pamięci podręcznej jest aktualna | Użyj zapisanej odpowiedzi |
| 400 Bad Request | Błędna składnia lub ogólne żądanie | Popraw żądanie |
| 401 Unauthorized | Brak prawidłowego uwierzytelnienia | Odśwież sesję albo pokaż logowanie |
| 403 Forbidden | Brak uprawnień | Pokaż brak dostępu |
| 404 Not Found | Zasób nie istnieje lub jest ukryty | Pokaż stan braku danych |
| 409 Conflict | Konflikt z aktualnym stanem | Odśwież dane lub rozwiąż konflikt |
| 410 Gone | Zasób został trwale usunięty | Usuń lokalne odwołanie |
| 412 Precondition Failed | ETag lub inny warunek nie pasuje | Pobierz aktualną wersję i rozwiąż konflikt |
| 415 Unsupported Media Type | Nieobsługiwany Content-Type | Wyślij dane w obsługiwanym formacie |
| 422 Unprocessable Content | Poprawna składnia, błędna treść | Pokaż błędy walidacji |
| 429 Too Many Requests | Przekroczono limit | Respektuj Retry-After, jeśli serwer go zwraca |
| 500 Internal Server Error | Nieoczekiwany błąd serwera | Pokaż bezpieczny komunikat i identyfikator żądania |
| 502 / 503 / 504 | Problem bramy lub dostępności | Ponawiaj tylko bezpieczne albo idempotentne operacje |
Odpowiedź 401 powinna zawierać odpowiedni nagłówek WWW-Authenticate, natomiast odpowiedzi 204 oraz 304 nie posiadają ciała, przez co bezwarunkowe wywołanie metody response.json() po stronie klienta zakończy się nieobsłużonym błędem.
Struktura odpowiedzi błędów w API
Wysokiej jakości API nigdy nie ogranicza się do prostego komunikatu tekstowego, ponieważ interfejs potrzebuje precyzyjnych wytycznych, czy wyświetlić błąd pod konkretnym polem, wymusić ponowne logowanie, odczekać chwilę czy zaoferować ponowienie akcji, a do tego celu służą w pełni strukturalne błędy, których obecnym standardem branżowym jest specyfikacja RFC 9457 Problem Details:
Pola type, title, status, detail oraz instance posiadają znaczenie ściśle określone przez specyfikację RFC, podczas gdy errors i code stanowią autorskie rozszerzenia kontraktu. Frontend powinien podejmować decyzje na podstawie statusu, pola type lub stabilnego kodu błędu zamiast parsować tekst dla człowieka z pola detail. Adres umieszczony w polu type powinien prowadzić do dokumentacji danego problemu, o ile jest zwykłym adresem URL, natomiast instance jednoznacznie identyfikuje konkretne wystąpienie błędu i pomaga w jego korelacji z logami systemowymi.
W środowisku produkcyjnym bezwzględnie nie wolno ujawniać stosu wywołań, zapytań SQL ani poufnych sekretów, ponieważ standard Problem Details służy wyłącznie do czytelnego opisu błędów w interfejsie HTTP, a nie do prezentowania wewnętrznej diagnostyki serwera końcowemu użytkownikowi.
Walidacja danych wejściowych w backendzie
Frontend może walidować formularz dla wygody użytkownika, ale backend musi walidować dane ponownie. Użytkownik może ominąć UI, wysłać żądanie przez curl, zmodyfikować treść żądania w DevTools albo użyć starej wersji aplikacji i dlatego właśnie walidacja musi stać na granicy API.
Typowy przepływ:
- Parsujesz JSON i sprawdzasz
Content-Type. - Ograniczasz rozmiar ciała żądania, długość pól i złożoność danych.
- Walidujesz dozwolony kształt danych schematem i odrzucasz nieznane pola.
- Normalizujesz dane tylko według jawnych reguł.
- Walidujesz reguły biznesowe i aktualny stan systemu.
- Polegasz na ograniczeniach bazy przy wyścigach.
- Zwracasz
422dla błędnej treści albo409dla konfliktu stanu.
Przykład z Zod:
Dla frontendowca najważniejsze jest to, żeby backend zwracał błędy w stabilnym formacie. Wtedy UI nie musi parsować losowych tekstów i może powiązać pointer z konkretnym polem formularza.
Waliduj także dane wychodzące. Nie zwracaj bezpośrednio całego obiektu z ORM, ponieważ może zawierać hash hasła, flagi administracyjne lub pola wewnętrzne. Jawny DTO albo schema odpowiedzi ogranicza ryzyko przypadkowego wycieku przy późniejszej zmianie modelu.
Paginacja w API dla list i tabel
Brak limitu na liście to jeden z tych błędów, które potrafią długo pozostawać w ukryciu, ponieważ przy trzydziestu rekordach wszystko działa bez problemu. Przy trzydziestu tysiącach endpoint API zaczyna drastycznie zużywać pamięć, obciążać bazę danych i generować ogromny transfer. Właśnie dlatego wszystkie publiczne listy powinny mieć narzucony maksymalny limit, a większe zbiory danych dodatkowo wspierać paginację.
W praktyce najczęściej spotyka się dwa podstawowe wzorce, z których offset i limit są najprostsze i doskonale wspierają numerowane strony, choć przy dużych wartościach offsetu stają się kosztowne ze względu na to, że baza danych wciąż musi wykonać pracę nad obliczeniem i pominięciem wcześniejszych wierszy.
Paginacja kursorowa dobrze pasuje do feedów i nieskończonego przewijania. Pozwala kontynuować od ostatniego elementu bez kosztu dużego offsetu.
Każda paginacja wymaga jawnego ORDER BY, który daje jednoznaczną kolejność. Samo created_at nie wystarczy, gdy kilka rekordów ma ten sam czas. Użyj pary (created_at, id) zarówno w sortowaniu, jak i warunku kolejnej strony.
Backend może zwrócić 20 elementów i zbudować nextCursor z dodatkowego, dwudziestego pierwszego rekordu. Kursor powinien być nieprzezroczysty dla klienta. Base64 jest tylko kodowaniem, nie zabezpieczeniem. Waliduj pola kursora, a jeśli klient nie może ich modyfikować, podpisz token.
Offset sprawdza się w panelach z numeracją stron i potrzebą przejścia do strony 12. Kursor jest zwykle lepszy dla zmieniającego się feedu. Żaden wariant nie gwarantuje pełnej migawki danych bez dodatkowej strategii spójności. Liczenie dokładnego total także może być kosztowne przy złożonych filtrach.
Filtrowanie i sortowanie
Filtrowanie i sortowanie powinny mieć przewidywalną konwencję. Dzięki temu frontend nie musi uczyć się osobnego stylu dla każdego endpointu API.
status=publishedfiltruje status,sort=-created_atustawia kolejność malejącą,fields=id,titleogranicza zwracane pola.
Backend musi stosować listę dozwolonych filtrów, pól i kierunków sortowania. Nie wstawiaj nazw kolumn ani fragmentów ORDER BY bezpośrednio z parametrów query string do SQL.
Idempotencja i bezpieczne ponawianie żądań POST
Frontend czasem musi ponowić żądanie, gdy połączenie zostanie zerwane albo pojawi się przekroczenie limitu czasu. Brak odpowiedzi nie mówi, czy serwer wykonał operację. Przy POST możesz więc przypadkiem stworzyć drugie zamówienie albo podwójną płatność. Klucz idempotencji pozwala backendowi rozpoznać tę samą intencję użytkownika.
Klucz utwórz raz dla konkretnej intencji, na przykład kliknięcia „Zapłać”, zapisz go razem ze stanem operacji i wykorzystuj ponownie przy ponowieniu. Wygenerowanie nowego UUID przy każdej próbie omija ochronę.
Backend powinien:
- Ograniczyć zakres klucza do użytkownika lub tenanta, endpointu API i rodzaju operacji.
- Atomowo zarezerwować klucz przed wykonaniem efektu, używając unikalnego ograniczenia lub blokady.
- Zapisać hash istotnych parametrów. Ten sam klucz z inną treścią żądania musi zwrócić błąd.
- Rozróżniać status
processing, zakończoną odpowiedź i błąd, aby równoległe żądania nie wykonały operacji dwa razy. - Przechować status HTTP i ciało odpowiedzi przez jawnie udokumentowany czas.
Okres retencji zależy od API. Stripe API v1 pozwala usuwać klucze po co najmniej 24 godzinach, ale nie jest to uniwersalna wartość dla własnego systemu.
GET, PUT i DELETE są idempotentne według semantyki HTTP, jeśli backend implementuje je poprawnie. Idempotencja dotyczy zamierzonego efektu na serwerze, więc drugi DELETE może zwrócić inny status i nadal być idempotentny. POST nie jest idempotentny z definicji, a PATCH zależy od operacji. Ustawienie status=paid może być idempotentne, ale zwiększenie quantity o 1 już nie.
Kontrola współbieżności i ochrona przed utratą zmian
Idempotency key chroni przed powtórzeniem tej samej intencji, ale nie rozwiązuje konfliktu dwóch różnych edycji. Gdy dwie osoby otwierają ten sam rekord, późniejszy zapis może nadpisać wcześniejszą zmianę. Backend może zwrócić ETag opisujący wersję zasobu, a klient odesłać go w If-Match.
Jeśli aktualna wersja jest już inna, serwer zwraca 412 Precondition Failed zamiast nadpisywać dane. Podobny mechanizm można oprzeć na kolumnie version aktualizowanej warunkowo w bazie. Frontend powinien wtedy pobrać aktualne dane i pomóc użytkownikowi rozwiązać konflikt.
Wersjonowanie API
API żyje razem z produktem. Dzisiaj pole name wystarcza, jutro pojawia się displayName, za miesiąc dochodzi aplikacja mobilna, a za pół roku integracja partnerska. Wersjonowanie jest sposobem na zmianę kontraktu bez rozbijania istniejących klientów.
W URL jest proste i czytelne:
W typie mediów utrzymuje stały URL:
Bez jawnego numeru wersji wymaga zmian kompatybilnych wstecz:
- nie usuwasz pola, dopóki korzystają z niego wspierani klienci,
- nie zmieniasz znaczenia ani typu istniejącego pola,
- nowe informacje dodajesz w opcjonalnych polach,
- dokumentujesz okres wsparcia i sposób migracji.
Stara wersja działa równolegle przez okres deprecacji. RFC 9745 definiuje nagłówek Deprecation jako datę w formacie Structured Fields. RFC 8594 definiuje Sunset jako moment planowanego wyłączenia zasobu.
Sama obecność nagłówka nie zastępuje komunikacji z użytkownikami API, przewodnika migracji ani telemetrycznego sprawdzenia, którzy klienci nadal korzystają ze starej wersji.
OpenAPI i kontrakt API
Najgorszy rodzaj integracji to taki, w którym frontend zgaduje kształt odpowiedzi na podstawie jednego przykładu ze Slacka. Kontrakt API powinien być jawny: endpointy, parametry, statusy, ciało żądania, ciało odpowiedzi i przykłady błędów.
W REST najczęściej robi się to przez OpenAPI:
Co to daje frontendowi:
mniej zgadywania i domyślania się pól przy integracji z backendem,
możliwość automatycznego wygenerowania spójnych typów TypeScript bezpośrednio z kontraktu,
jednoznaczne statusy oraz przewidywalne formaty błędów ułatwiające obsługę w interfejsie,
prostsze i bardziej niezawodne mockowanie API w testach jednostkowych oraz integracyjnych,
szybsze i łatwiejsze wykrywanie wszelkich zmian łamiących wsteczną kompatybilność (breaking changes).
Jeśli pracujesz w TypeScripcie, dobrą alternatywą może być tRPC, w pełni typowane route handlers albo wspólne schematy Zod bądź Valibot, pamiętając jednak o tym, że same typy TypeScript nie walidują danych w trakcie działania programu. Aby dokumentacja nie rozjechała się z rzeczywistą implementacją, kontrakt powinien być regularnie sprawdzany za pomocą testów konsumenckich lub walidacji schematu w procesie CI.
BFF, czyli Backend for Frontend
BFF, czyli Backend for Frontend, to wzorzec, w którym frontend ma „swój” backend dopasowany do potrzeb konkretnego UI. Chodzi tutaj o sytuacje, w których ekran musi zebrać dane z kilku usług, ukryć sekrety albo przekształcić odpowiedź pod konkretny widok. W Next.js route handlers i server actions często pełnią właśnie tę rolę:
Korzyści są następujące:
- Frontend nie musi znać topologii usług.
- Przeglądarka wykonuje mniej żądań.
- Klucze usług pozostają po stronie serwera.
- Kontrakt może być dopasowany do konkretnego interfejsu.
Minusem jest dodatkowa warstwa do utrzymania, ponieważ BFF musi mieć własne limity czasu, kontrolę współbieżności, obsługę częściowych awarii i budżet czasu odpowiedzi. Promise.all nie anuluje pozostałych żądań po błędzie jednego z nich, a wolna usługa nadal może opóźnić cały ekran.
Obserwowalność, logi i identyfikator żądania w backendzie
Frontendowiec często widzi tylko komunikat: „coś poszło nie tak”. Backend powinien dawać zespołowi sposób na znalezienie konkretnego żądania w logach.
Minimum produkcyjne:
Identyfikator żądania identyfikuje pojedyncze żądanie. Jeśli akceptujesz wartość od klienta, waliduj jej format i długość.
Kontekst śledzenia łączy wywołania między usługami. Standard W3C Trace Context używa między innymi nagłówka
traceparent.Strukturalne logi zawierają metodę, wzorzec trasy, status i czas odpowiedzi. Nie zapisuj pełnego URL-a z sekretami ani identyfikatora użytkownika bez potrzeby.
Metryki pokazują liczbę żądań, p95 i p99 opóźnienia oraz błędy.
Ślady pokazują czas spędzony w bazie i usługach zależnych.
Redakcja danych usuwa hasła, tokeny, pełne dane kart i wrażliwe dane osobowe.
OpenTelemetry dostarcza standardy i narzędzia do telemetryki, ale nie jest samym systemem przechowywania ani prezentacji danych. Dane trzeba wysłać do zgodnego kolektora i backendu obserwowalności.
Przykładowa odpowiedź może zawierać identyfikator żądania:
Dzięki temu użytkownik może wysłać wsparciu technicznemu identyfikator błędu, frontend może dołączyć go do raportu, a backend znajduje dokładny wpis w logach.
4. CORS i problemy integracji frontendu z backendem
CORS jest frustrujący, bo wygląda jak błąd backendu, a tak naprawdę jest protokołem egzekwowanym przez przeglądarkę. Skrypt nie może swobodnie odczytać odpowiedzi z innego originu, na przykład z https://api.example.com, gdy działa pod https://app.example.com. To rozszerzenie modelu Same-Origin Policy.
Jak działa CORS?
Dla tak zwanych simple requests przeglądarka wysyła żądanie bez wcześniejszego preflightu. Metoda musi być GET, HEAD albo POST, a nagłówki i Content-Type muszą mieścić się na liście bezpiecznej CORS. Authorization nie jest nagłówkiem safelisted, więc jego użycie uruchamia preflight.
Jeśli nagłówka brakuje albo origin się nie zgadza, przeglądarka nie udostępni odpowiedzi JavaScriptowi. Samo żądanie mogło dotrzeć do serwera i wykonać efekt, dlatego CORS nie chroni przed CSRF.
Dla żądań niespełniających kryteriów simple request przeglądarka najpierw wysyła preflight OPTIONS.
Backend musi odpowiedzieć:
Dopiero po poprawnym preflighcie przeglądarka wysyła właściwe żądanie. Access-Control-Max-Age pozwala przeglądarce przechować wynik preflightu, ale rzeczywisty czas jest ograniczany także przez implementację przeglądarki. Gdy serwer dynamicznie zwraca origin z listy dozwolonych adresów, powinien dodać Vary: Origin, aby pamięć podręczna nie pomieszała odpowiedzi dla różnych originów.
Konfiguracja CORS w Express
Typowe pułapki CORS
Wildcard nie działa z żądaniem uwierzytelnionym. Przy credentials musisz zwrócić konkretny dozwolony origin.
Fetch domyślnie wysyła credentials tylko do tego samego originu. Dla żądania cross-origin ustaw
credentials: 'include', a backend musi zwrócićAccess-Control-Allow-Credentials: true.Polityka cookie nadal obowiązuje.
SameSite, blokowanie third-party cookies i zakresDomainmogą zatrzymać cookie mimo poprawnego CORS.Origin porównuj z dokładną listą dozwolonych adresów. Nie używaj luźnego sprawdzenia typu
endsWith('example.com'), które może zaakceptować domenę atakującego.Subdomena i port zmieniają origin.
app.example.com,api.example.com,localhost:3000ilocalhost:5173to różne originy.Nagłówki odpowiedzi mogą wymagać
Access-Control-Expose-Headers. Bez niego JavaScript nie odczyta dowolnego własnego nagłówka, na przykład identyfikatora żądania.
Dlaczego CORS to nie autoryzacja?
CORS kontroluje, czy JavaScript w przeglądarce może odczytać odpowiedź z innego originu. Curl, aplikacja mobilna i żądanie wykonywane po stronie serwera mogą wywołać API bez oglądania się na CORS. Dlatego CORS nie zastępuje autoryzacji. Token, sesja i kontrola uprawnień są osobną warstwą.
Nie myl też CORS z ochroną przed CSRF. Jeśli używasz cookies i sesji, nadal potrzebujesz poprawnego SameSite, tokenów CSRF albo innej strategii ochrony operacji mutujących.
Lista kontrolna przed integracją endpointu API
Zanim frontend zacznie podpinać endpoint API, najlepiej ustalić kilka rzeczy:
- Jaki jest pełny URL, metoda HTTP i wymagane nagłówki?
- Czy endpoint API wymaga sesji, tokenu, ról albo konkretnych uprawnień?
- Jak wygląda ciało żądania i które pola są opcjonalne?
- Jakie statusy może zwrócić endpoint API:
200,201,204,401,403,409,422,429,5xx? - Jaki jest stabilny format błędu i czy błędy formularza są mapowane per pole?
- Czy lista ma paginację, sortowanie, filtrowanie i limit maksymalny?
- Czy operację można ponowić i czy wymaga klucza idempotencji?
- Czy odpowiedź można buforować w pamięci podręcznej i jak długo?
- Czy endpoint API zwraca identyfikator żądania do debugowania?
- Czy istnieje OpenAPI, typ TypeScript, schemat Zod albo inny kontrakt?
Jeśli nie znasz odpowiedzi na te pytania, frontend i tak będzie musiał je odkryć metodą prób i błędów. Lepiej ustalić kontrakt, zanim UI zacznie zależeć od domysłów.
Problem w UI a możliwa przyczyna backendowa
| Objaw w interfejsie | Możliwa przyczyna backendowa |
|---|---|
| Lista długo pokazuje spinner | Brak indeksu, N+1, brak paginacji, wolne JOIN-y |
| Formularz pokazuje ogólny błąd | Brak strukturalnych błędów per pole |
| Użytkownik jest wyrzucany do logowania | 401, wygasła sesja, problem z cookie/tokenem |
| Zalogowany użytkownik widzi "brak dostępu" | 403, brak roli lub uprawnienia |
| Podwójne zamówienie po kliknięciu | Brak idempotency key albo blokady po stronie UI |
| Żądanie działa w Postmanie, ale nie w UI | CORS, cookies, credentials, różny origin |
| Infinite scroll gubi lub duplikuje dane | Offset pagination przy zmieniającym się zbiorze |
| Produkcja działa wolniej niż staging | Brak poolingu, zimne starty, inna skala danych |
