API design. 7 zasad, które zaoszczędzą Tobie i frontendowcom kilka miesięcy refaktoru
Pierwsze API, które zaprojektowałem w 2019, było katastrofą. Każdy endpoint zwracał inną strukturę odpowiedzi. Niektóre rzucały błędy jako 200 z polem error. Niektóre używały HTTP status codes losowo. Frontendowcy w zespole klienta przepłakali to przez 6 miesięcy.
Dziś API, które buduję dla klientów w agencji, wyglądają konsystentnie, są łatwe do dokumentowania i frontendowcy nie krzyczą.
Ten post to 7 zasad, które wyciągnąłem z 6 lat budowania API. Bez akademickich definicji REST. Konkretne praktyki, które ratują projekt przed refaktorem.
Zasada 1: konsystentna struktura odpowiedzi
Najczęstszy grzech młodego API. Każdy endpoint zwraca inną strukturę.
Złe:
// GET /users/123
{ "id": 123, "name": "Jan" }
// GET /products
{ "data": [...], "total": 50 }
// GET /orders/456
{ "order": { "id": 456 } }
Trzy różne struktury, każdy z innym kluczem dla danych. Frontend musi pamiętać “dla users to plain object, dla products to data, dla orders to order”. Recipe for bugs.
Dobre:
// GET /users/123
{ "data": { "id": 123, "name": "Jan" }, "meta": null }
// GET /products
{ "data": [...], "meta": { "total": 50, "page": 1 } }
// GET /orders/456
{ "data": { "id": 456 }, "meta": null }
Jeden wzorzec. Frontend zawsze sięga po response.data. meta zawiera dodatkowe info (paginacja, totals, czas), gdy potrzebne.
Można też używać konwencji JSON:API, ale dla większości projektów własna prosta konwencja wystarczy. Ważne, żeby była JEDNA.
Zasada 2: HTTP status codes używaj poprawnie
Drugi najczęstszy grzech. Wszystkie odpowiedzi jako 200, nawet błędy.
Złe:
GET /users/999 (nie istnieje)
HTTP 200
{ "error": "User not found" }
Dobre:
GET /users/999 (nie istnieje)
HTTP 404
{ "error": { "code": "USER_NOT_FOUND", "message": "User not found" } }
Status codes, których naprawdę potrzebujesz w 90% API:
- 200 OK: udane GET, POST, PUT, PATCH
- 201 Created: udane POST z stworzeniem zasobu (zwracaj
Locationheader) - 204 No Content: udane DELETE (lub PUT bez zwracania danych)
- 400 Bad Request: błąd walidacji (np. brakujące pole)
- 401 Unauthorized: brak autentykacji (lub niepoprawna)
- 403 Forbidden: jest auth, ale nie ma uprawnień
- 404 Not Found: zasób nie istnieje
- 409 Conflict: konflikt z istniejącym stanem (np. duplicate email)
- 422 Unprocessable Entity: walidacja semantyczna failed
- 429 Too Many Requests: rate limit hit
- 500 Internal Server Error: błąd serwera
- 503 Service Unavailable: serwis przeciążony / w utrzymaniu
Inne kody używaj tylko, jeśli wiesz dokładnie po co. Lepiej 5 znanych niż 50 mylonych.
Zasada 3: paginate WSZYSTKO, co może urosnąć
Endpoint, który zwraca listę, zawsze pagunj. Nawet jeśli dziś jest 10 rekordów, jutro może być 10 000.
Złe:
GET /products
[ { ... }, { ... }, { ... } ] // 50 000 produktów, MB danych, 5 sek
Dobre:
GET /products?page=1&pageSize=20
{
"data": [...],
"meta": {
"page": 1,
"pageSize": 20,
"total": 50000,
"totalPages": 2500
}
}
Dwa style paginacji:
Offset-based (page + pageSize): łatwiej, większości API wystarczy. Problem przy bardzo dużych offset’ach (DB wolne).
Cursor-based (cursor + limit): wydajniejsze dla skali, stabilne przy zmieniających się danych. Trudniejsze w implementacji.
Dla 95% API offset wystarczy. Cursor dorzucasz, kiedy faktycznie boli (Twitter feed, infinite scroll na bardzo aktywnych danych).
Zasada 4: filtruj i sortuj przez query params, nie różne endpointy
Złe:
GET /products/active
GET /products/inactive
GET /products/by-category/123
GET /products/sorted-by-price-asc
Każda zmiana = nowy endpoint. Niemożliwe do utrzymania.
Dobre:
GET /products?status=active&category=123&sort=price:asc
Jeden endpoint, kombinacje przez parametry. Frontend ma elastyczność, backend ma jeden punkt logiki.
Konwencja sortowania, którą polecam:
?sort=price:asc,createdAt:desc
Filter: po polach equality (?status=active). Dla zakresów: ?priceMin=10&priceMax=100.
Skomplikowane filtry (AND/OR/NOT) zwykle znaczą, że potrzebujesz dedykowanego endpointu (np. /products/search) z body JSON.
Zasada 5: wersjonowanie od pierwszej linijki kodu
Niedoceniana zasada, dopóki nie boli.
W pierwszej wersji API nie myślisz o breaking changes. W drugiej okazuje się, że trzeba zmienić strukturę, ale 30 klientów już używa starej. Migrate’ujesz wszystkich na siłę = łamiesz produkcję. Nie zmieniasz nic = utykasz w technicznym długu.
Wersjonuj od dnia 1. Dwie konwencje:
URL versioning (najczęstsze):
GET /v1/products
GET /v2/products (po zmianach)
Header versioning (czystsze, mniej popularne):
GET /products
Accept: application/vnd.api+json; version=1
Wybierz jedno, trzymaj się. Dla 90% projektów URL versioning jest bezpieczniejsze (łatwiej dla debug, dla cache, dla frontu).
Zasada 6: error handling musi być przewidywalny
Frontend musi wiedzieć, jak obsłużyć błędy. Konsystentny format pomaga.
Złe:
{ "error": "Email already taken" }
{ "errors": [{ "field": "email", "message": "taken" }] }
{ "msg": "Validation failed", "details": "Email exists" }
Trzy różne struktury w jednym API. Frontend musi pisać 3 różne handlery.
Dobre:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Validation failed",
"details": [
{
"field": "email",
"code": "ALREADY_TAKEN",
"message": "Email is already in use"
}
]
}
}
Każdy błąd ma:
- code: machine-readable (frontend może switch’ować po nim)
- message: human-readable (do logów albo UI)
- details (opcjonalne): array błędów per pole
Frontend pisze jeden handler, działa wszędzie.
Zasada 7: dokumentacja jako kod, nie jak ostatnia myśl
Najczęstszy fail: API zbudowane, dokumentacja w Wordzie z 6 miesięcy temu, nieaktualna, nikt jej nie czyta.
Standardy:
OpenAPI (Swagger): dokumentacja w YAML/JSON, generuje interaktywną dokumentację. Tools: Swagger UI, Redoc, Stoplight.
Generatory z kodu: TSOA dla TypeScript, FastAPI dla Pythona, Spring dla Javy. Dekoratorach w kodzie generujesz docs automatycznie.
Tests as docs: napisz przykładowe requesty/responses w testach. Postman collections. cURL examples w README.
Moja stack: OpenAPI generowany z kodu TypeScript (TSOA), wynik wrzucany na Stoplight albo własną stronę z Redoc. Update’uje się automatycznie z code change’em.
Bonus: SDK generation. Z OpenAPI generujesz client SDK w 20 językach. Frontend dostaje typesafe SDK, nie pisze fetch’ów ręcznie.
REST vs GraphQL vs RPC
Krótko, bo to temat na osobny post.
REST: standard w 80% projektów. Łatwy, dobrze ugruntowany, ogromne tooling.
GraphQL: świetny, gdy klient potrzebuje elastyczności w doborze pól (np. dashboard z różnymi widokami). Overhead w prostych CRUDach. Nadmierne dla większości projektów.
tRPC: jeśli stack JS/TS end-to-end. Typesafe API, zero codegen. Świetny dla monorepo Next.js + backend Node.
gRPC: backend-to-backend, internal services. Performance-critical. Niespecjalnie do API publicznych.
Dla agencji robiącej projekty klientów: REST default, tRPC dla monorepo, GraphQL na życzenie klienta jeśli ma sens biznesowy.
Autentykacja: JWT vs sesje
JWT (JSON Web Tokens): stateless, signed, klient trzyma w localStorage albo cookie. Skalowalne, ale revocation trudne. Standard dla SPA, mobile.
Sesje (cookie + server-side store): stateful, server pamięta o sesji. Łatwiejsze revocation. Klasyczne dla SSR app.
Dla nowoczesnych projektów Next.js + Strapi: JWT z refresh tokens. Lepiej skaluje, łatwiej w mobile, lepiej dla microservices.
Refresh token strategy:
- Access token: krótki (15 min), w memory
- Refresh token: dłuższy (7 dni), w HttpOnly cookie
- Refresh endpoint: dostaje refresh token, zwraca nowy access token
- Logout: revoke refresh token w bazie
Bez refresh tokens user musi się logować co 15 minut. To bezsensowne.
Rate limiting: kiedy i jak
Każde publiczne API potrzebuje rate limiting. Inaczej jeden klient zatkanym serwer.
Strategie:
- Token bucket: klient ma N tokenów, każdy request zjada 1. Tokeny regenerują się X/sek.
- Sliding window: ile requestów w ostatnich N minutach.
- Fixed window: ile requestów w danym kwadransie/godzinie.
Library, której używam: express-rate-limit (Node), slowapi (Python), wbudowane w Strapi v5.
Headers, które warto zwracać:
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 47
X-RateLimit-Reset: 1716105600
Frontend dostosowuje retry behavior.
Webhooks: kiedy push, kiedy poll
Push (webhook): klient daje URL, API woła go przy zmianie. Niska latencja, mniej obciążenia.
Poll: klient sam pyta co X sekund. Prostsze, ale wolniejsze i obciąża API.
Wybierz webhook, jeśli:
- Latencja ma znaczenie
- Klient jest serwerem, nie aplikacją w browser
- Wydarzenia są rzadkie ale ważne (płatność, status zamówienia)
Wybierz polling, jeśli:
- Klient jest browserem (webhooks z browsera trudne)
- Wydarzenia są częste i mało krytyczne (dashboard metrics)
- Klient ma firewall blokujący incoming
Mój standard: webhooks dla event-driven flows, polling dla dashboards.
Najczęstsze błędy
Brak idempotency w POST. Klient pingnie POST 2 razy z powodu network glitch, masz 2 zamówienia. Dodaj Idempotency-Key header.
Wystawianie wewnętrznych ID. Jeśli używasz auto-increment ID, ktoś może iterować po /users/1, /users/2. Użyj UUID albo coding internal ID.
Brak CORS configuration. Frontend z innej domeny dostaje 403. Skonfiguruj CORS od dnia 1.
Sekrety w odpowiedzi. API zwraca passwordHash, refreshToken, internalNotes. Filtruj odpowiedzi przez DTO/Serializer.
Brak request ID. Klient zgłasza “request 5 minut temu padł”. Bez X-Request-ID szukasz po linii w logach. Z request ID = grep do jednego konkretnego flow.
Logowanie bez sanityzacji. Logi pełne tokenów, haseł, danych osobowych. Sanityzuj przed log.
Co czytać dalej
- “REST API Design Rulebook” Mark Massé. Klasyk, krótki, esencjonalny.
- “API Design Patterns” JJ Geewax. Współczesny, praktyczny, oparty na Google API Design.
- stripe.com/docs. Najlepsze API w branży. Jak masz wątpliwość, sprawdź, jak Stripe to zrobił.
- OpenAPI Specification. Standard, którego warto znać.
Od czego zacząć
Jeśli projektujesz pierwsze API:
- Wybierz konwencję response struct. Wpisz w
CONTRIBUTING.md. Trzymaj się. - Sprawdź każdy status code, którego używasz. Czy poprawnie?
- Paginate wszystko, co może urosnąć.
- Wersjonuj URL od dnia 1 (
/v1/). - Konsystentny error format.
- OpenAPI generowane z kodu, hostowane gdzieś dostępnym.
- Rate limiting od dnia 1, nawet jeśli low limit.
Dobre API projektuje się raz. Złe API refaktoruje się przez 2 lata. Inwestuj na początku, oszczędzaj potem.
Pisałem o stack agencji w postach o TypeScript, Next.js, TurboRepo. Dobry API design to fundament reszty.