Dlaczego wersjonowanie API potrafi zabić tempo rozwoju produktu
Konflikt interesów: frontend chce szybciej, backend broni kontraktu
Frontend i aplikacje mobilne są bezpośrednio widoczne dla użytkownika, więc presja na tempo zmian jest duża. Nowy landing, dodatkowy krok w lejku, zmiana przepływu zakupowego – to wszystko wymaga nowych danych i nowych akcji po stronie API. Jeśli backend jest zbyt mocno przywiązany do starego kontraktu, każda zmiana UI kończy się dyskusją:
- „Nie możemy zmienić tego pola, bo stara aplikacja mobilna się wywali.”
- „Nie możemy podzielić tego endpointu, bo stare SPA używa obecnego formatu.”
- „Najpierw zróbmy v2, przenieśmy część ruchu, potem pomyślimy, co dalej.”
Taki wzorzec hamuje eksperymenty po stronie interfejsu. Produkt chce robić A/B testy, a backend wstrzymuje zmiany z obawy przed złamaniem istniejących klientów. Bez przemyślanej strategii wersjonowania API wszystko staje się albo ryzykownym „hotfixem” w produkcji, albo czasochłonną migracją do nowego /v2, która nigdy nie jest skończona.
Sprzężenie cyklu wydawniczego API i mobilki
Przy aplikacjach mobilnych dochodzi dodatkowe ograniczenie: cykl releasów w sklepach (App Store, Google Play). Nawet jeśli backend wdroży zmianę kontraktu API w 10 minut, to użytkownicy aktualizują aplikację w ciągu dni, a nawet tygodni. Skutki:
- Backend musi utrzymywać kompatybilność ze starymi wersjami aplikacji znacznie dłużej niż z webowym frontendem.
- Każda „twarda” zmiana API wymaga zaplanowania kampanii aktualizacji aplikacji, komunikacji z użytkownikami oraz bufora czasowego.
- Nie da się „cofnąć” błędnej zmiany API, jeśli nowa wersja aplikacji już ją wykorzystuje i jest w rękach tysięcy użytkowników.
Wersjonowanie API jest tu mechanizmem rozsprzęgania cykli wydawniczych. Dobra strategia pozwala backendowi wprowadzać zmiany, jednocześnie pozostawiając starszym aplikacjom w miarę stabilne środowisko, dopóki użytkownicy się nie zaktualizują.
Koszt „tanich” decyzji na początku projektu
Na starcie projektu często zapada klasyczna decyzja: „Nie ma co przesadzać z wersjami, zrobimy po prostu /api i będziemy dostosowywać, jak będzie potrzeba”. Przez kilka pierwszych miesięcy działa to całkiem nieźle. Problem zaczyna się, gdy:
- pojawia się pierwsza większa refaktoryzacja modelu domenowego,
- powstaje nowa aplikacja mobilna, która potrzebuje innych danych niż obecny frontend,
- biznes wymusza zmianę sposobu naliczania cen, rabatów, statusów itp.
Brak spójnego podejścia do wersjonowania kończy się tym, że:
- „stare” kontrakty są łatane ifami,
- nowe funkcje są dodawane ad hoc bez jasnej polityki kompatybilności,
- po kilku latach nikt nie wie, które endpointy można bezpiecznie usunąć.
Strategia wersjonowania API nie musi być skomplikowana. Musi być spójna i przede wszystkim – znana zespołom frontendowym i mobilnym. Bez tego projekt bardzo szybko zaczyna płacić „odsetki” od technicznego długu w postaci blokad i długich okien migracyjnych.
Kontrakt między klientami a backendem
API jest kontraktem. Ten kontrakt to nie tylko URL i JSON, ale cały zestaw oczekiwań:
- jakie pola pojawiają się w odpowiedzi i jakiego są typu,
- jakie kody HTTP są zwracane w jakich sytuacjach,
- jakie błędy i kody błędów mogą się pojawić,
- jakie są reguły biznesowe (np. czy można usunąć użytkownika, który ma aktywne zamówienia).
Gdy backend jednostronnie zmienia kontrakt, frontend i mobilka płacą cenę w postaci błędów na produkcji, nieprzewidzianych stanów UI i trudnych do odtworzenia bugów. Wersjonowanie API jest sposobem, żeby:
- określić, które wersje kontraktu są obsługiwane,
- zaplanować wycofanie starych kontraktów,
- dać klientom czas na migrację i testy.
Silny, ustalony kontrakt plus jasne reguły wersjonowania pozwalają backendowi zmieniać się bez paraliżowania innych zespołów.
Równoległy rozwój wielu klientów
Wraz z rozwojem produktu pojawiają się kolejne interfejsy: nowe SPA, panel administracyjny, aplikacja partnerska, dedykowane aplikacje B2B. Wszystkie one korzystają z jakiejś formy API. Bez przemyślanej polityki wersjonowania:
- każdy klient zaczyna „wystrzykiwać” własne hacki i customowe parametry,
- backend zaczyna implementować specyfikę UI poszczególnych klientów bez jasnych granic,
- zmiana w jednym kliencie przypadkiem łamie innego klienta, bo dzielą endpointy.
Odpowiednio ustawiona strategia wersjonowania (np. rozdzielenie core API i warstwy BFF dla frontendu/mobilki, wersjonowanie per zasób, a nie „globalne v2”) pozwala na niezależne tempo rozwoju poszczególnych klientów, bez eksplozji wersji i bez dublowania całych API.
Podstawowe pojęcia: wersja, kontrakt, kompatybilność
Czym jest kontrakt API w praktyce
Kontrakt API to zapisane (formalnie lub nieformalnie) oczekiwania między klientem (frontend, mobile, inny serwis) a backendem. Obejmuje:
- schemat danych – struktura JSON, typy pól, wymagane i opcjonalne atrybuty,
- zachowanie – co się stanie, jeśli wywołasz endpoint z danymi wejściowymi X,
- statusy HTTP – jakie kody są zwracane i co oznaczają,
- błędy – format błędów, kody błędów biznesowych, komunikaty,
- warunki brzegowe – limity, paginacja, sortowanie, filtry.
Kontrakt może być opisany w OpenAPI/Swagger, w GraphQL SDL, w dokumentacji MD lub w testach kontraktowych. Im bardziej formalnie jest wyrażony, tym łatwiej:
- zidentyfikować breaking changes,
- generować klientów i mocki,
- weryfikować zgodność zmian na CI.
Rodzaje zmian w API: dodające, modyfikujące, usuwające
Z perspektywy wersjonowania API zmiany można podzielić na trzy kategorie:
- Dodające (non-breaking) – dodanie nowego pola, które jest opcjonalne; dodanie nowego endpointu; dodanie nowej wartości w enumie, jeśli klienci potrafią ją zignorować.
- Modyfikujące – zmiana typu pola, zmiana znaczenia istniejącej właściwości, zmiana domyślnego zachowania endpointu, zmiana statusu HTTP w danym przypadku.
- Usuwające (breaking) – usunięcie pola, endpointu, zmiana wymaganych pól (dodanie nowego pola obowiązkowego), „przesuwanie” semantyki do innego miejsca.
Wersjonowanie API polega m.in. na tym, aby:
- maksymalnie dużo zmian klasyfikować jako dodające (i projektować API tak, aby było to możliwe),
- zmiany modyfikujące i usuwające wprowadzać świadomie, w zaplanowany sposób,
- dla zmian breaking zapewnić albo nową wersję kontraktu, albo plan migracji z buforem czasowym.
Kompatybilność wsteczna i do przodu
Kompatybilność wsteczna (backward compatibility) oznacza, że stary klient działa z nowym API. Przykład: frontend 1.0, który oczekuje pól id i name, dalej poprawnie działa, gdy backend 1.3 dodał pole displayName, ale nie usunął starych pól, a ich semantyka pozostała ta sama.
Kompatybilność do przodu (forward compatibility) to sytuacja, w której nowy klient potrafi pracować ze starszym API. Trudniejsze do osiągnięcia, ale bywa przydatne w środowiskach, gdzie nie masz pełnej kontroli nad kolejnością wdrożeń. Przykład: nowa aplikacja mobilna 2.0 potrafi działać z API 1.0, jeśli niektórych nowych pól po prostu nie otrzyma.
Dobrze zaprojektowane API (szczególnie JSON/REST) zwykle dąży do:
- zapewnienia kompatybilności wstecznej przez długi czas,
- ograniczonej kompatybilności do przodu przez defensywne parsowanie i sensowne defaulty po stronie klienta.
Semantyczne wersjonowanie a wersjonowanie API
SemVer (Semantic Versioning) z bibliotek znasz jako MAJOR.MINOR.PATCH. W API klasyczny SemVer nie zawsze ma bezpośrednie zastosowanie, ale zasady są podobne:
- MAJOR – zmiany breaking (złamanie kontraktu),
- MINOR – zmiany dodające (non-breaking),
- PATCH – poprawki błędów bez zmian w kontrakcie.
Różnica: API jest konsumowane przez wiele niezależnych klientów, często długo żyjących (szczególnie mobilki). Zwiększenie MAJOR w bibliotece oznacza, że ktoś musi zmienić wersję w package.json. Zwiększenie MAJOR w API oznacza migrację realnych użytkowników, często z utrudnionym update’em.
Z tego powodu w wersjonowaniu API bardziej opłaca się:
- rzadko zmieniać „MAJOR” (np.
/v1→/v2), - traktować większość zmian jako ewolucję wewnątrz jednej wersji,
- trzymać „semantykę” zmian bardziej w dokumentacji i changelogach niż w numerku w URL.
Jak frontend i mobile odczuwają złamany kontrakt
Złamany kontrakt API po stronie klienta objawia się często subtelnie:
- „czasem” puste listy, bo zmienił się domyślny filtr,
- spinner, który się nie kończy, bo frontend czeka na pole, którego backend już nie zwraca,
- wykresy bez danych, bo zmienił się format daty,
- crash aplikacji mobilnej, bo deserializator nie radzi sobie z nowym typem pola.
Najbardziej niebezpieczne są zmiany, które nie skutkują natychmiastowym 500/400, tylko cichą zmianą zachowania. Użytkownik widzi błędne dane, ale system z punktu widzenia serwera „działa”. Dobre wersjonowanie API plus testy kontraktowe pomagają wychwycić takie miejsca jeszcze przed wdrożeniem.
Modele wersjonowania API – przegląd z perspektywy praktyka
API ewolucyjne bez widocznego versioningu
Model „evolutionary API” polega na tym, że nie wystawiasz jawnej wersji w URL ani nagłówkach. Masz jedno „żyjące” API, które zmienia się stopniowo, ale:
- nowe pola są tylko dodawane (nie usuwane),
- zmiany typu są wprowadzane w sposób kompatybilny (np. liczby zamieniane na string, ale nie odwrotnie),
- stare pola mogą zostać oznaczone jako przestarzałe (deprecated), ale wciąż działają przez długi czas.
Takie podejście ma sens:
- w małych projektach z jednym frontendem i brakiem wsparcia dla starych wersji aplikacji,
- w środowiskach wewnętrznych, gdzie wszystkie zespoły są blisko i dobrze się komunikują,
- w GraphQL, gdzie filozofia jest bardziej ewolucyjna (deprecjacja pól zamiast skakania po wersjach).
Kluczowe jest posiadanie bardzo ostrych zasad, co jest dozwolone jako zmiana w kontrakcie. Bez tego brak jawnego versioningu szybko zamienia się w chaos.
Wersja w ścieżce URL: /api/v1/
Najpopularniejszy i najbardziej intuicyjny model: /api/v1/users, /api/v1/orders. Argumenty za:
- wersja jest widoczna i zrozumiała dla każdego,
- łatwo równolegle utrzymywać dwie wersje (
/v1i/v2), - infrastruktura (reverse proxy, routing) zwykle naturalnie wspiera takie podejście.
Problemy i typowe nadużycia:
- tworzenie nowej wersji całości API (
/v2) przy pierwszej większej zmianie jednego endpointu, - brak planu wycofywania starych wersji –
/v1żyje latami, bo „może ktoś jeszcze używa”, - kopiowanie kodu i logiki biznesowej 1:1 do nowej wersji zamiast dedykowanej warstwy adaptacyjnej.
Wersja w URL sprawdza się dobrze, jeśli:
- nie nadużywasz kolejnych
v2,v3, - masz plan deprecjacji dla każdej wersji,
- maksymalizujesz kompatybilność wewnątrz danej wersji.
Dobrym nawykiem jest też rozdzielenie decyzji biznesowej „to jest nowe wydanie produktu” od decyzji technicznej „to jest nowe /v2 w API”. Często wystarczy w ramach jednej wersji ścieżki wprowadzić warstwę translacji (adapter) i pozwolić kilku generacjom klientów współistnieć, zamiast dublować całą powierzchnię API. W praktyce oznacza to np. obsługę starego kształtu payloadu i mapowanie go do nowego modelu domenowego wewnątrz serwisu.
Kiedy jednak pojawia się rzeczywiste nowe API – inne reguły autoryzacji, inny model danych, zupełnie inna filozofia błędów – lepiej uczciwie postawić /v2, niż udawać, że to „tylko kilka pól więcej”. Kluczowy jest proces: plan migracji, twarde daty końca wsparcia dla /v1, komunikacja z zespołami frontendu i mobilki oraz monitoring, kto jeszcze zawołuje starą wersję. Bez tego wersja w URL zamienia się w muzeum wszystkich decyzji z ostatnich lat.
Dobrze zaprojektowane versioning w ścieżce pomaga też odseparować tempo rozwoju klientów. Frontend może szybciej przejść na /v2, podczas gdy aplikacja mobilna z długim cyklem releasów zostaje na /v1 jeszcze przez jakiś czas. Warunek: backend team musi świadomie zarządzać dwiema wersjami kontraktu, a nie tylko „doklejać” kolejne ify bez czyszczenia starych zachowań.
Wersja w nagłówku: Accept-Version, custom media types
Drugi popularny model to ukrycie wersji w nagłówkach HTTP. Najczęściej spotykane warianty:
- nagłówek własny, np.
X-API-Version: 1lubAccept-Version: 1.3, - wersje w typach mediów (content negotiation), np.
Accept: application/vnd.myapp.v1+json.
Na pierwszy rzut oka wygląda to „bardziej REST-owo” niż /v1 w ścieżce. Rzeczywiście ma kilka zalet:
- URL mogą być stabilne i „ładne” – bez plejad
/v1,/v2, - różne wersje reprezentacji tego samego zasobu da się negocjować (serwer wybiera, którą zwrócić),
- można bogaciej wyrazić typ odpowiedzi – nie tylko numer wersji, ale też wariant (np.
v1-public,v1-internal).
Minusy ujawniają się, gdy trzeba to utrzymać:
- debugowanie staje się mniej intuicyjne – z samego URL nie wiesz, którą wersję dostał klient,
- część narzędzi (proxy, loggery, API gatewaye) trzeba do tego świadomie dostroić,
- frontend i mobilka muszą pilnować właściwych nagłówków przy każdym wywołaniu.
Praktycznie taki model:
- sprawdza się lepiej w środowiskach, gdzie masz dobre observability (logi z nagłówkami) i mocny gateway,
- bywa upierdliwy przy ręcznym testowaniu (curl/Postman – zawsze pamiętaj o nagłówkach),
- nie rozwiązuje problemu nadmiaru wersji – nadal możesz skończyć z
v1,v2,v3, różniących się detalami.
Tip: jeśli decydujesz się na content negotiation z Accept, zadbaj o konsekwentne nazwy typu mediów i maksymalnie prosty schemat, np. application/vnd.company.product.resource-v1+json. Ad-hoc nazwy typu „almost-json” i „new-json” po roku robią z tego śmietnik.
Wersje per-endpoint i „micro-versioning”
Spotykane głównie w większych organizacjach: zamiast jednego /v1 dla całego API, wersjonujesz pojedyncze zasoby lub operacje. Przykłady:
/users– wersja 1,/users:v2– wersja 2 (np. w gRPC),- nagłówek
Accept-Versionrozumiany „per endpoint”, nie globalnie dla API, - nowe pola tylko dla nowych klientów, selekcjonowane feature flagą lub parametrem.
Zaletą jest precyzja – nie robisz /v2 całego świata tylko dlatego, że adres użytkownika się rozjechał. Ceną jest złożoność:
- mapa „kto z czego korzysta” szybko robi się trudna do ogarnięcia,
- w testach musisz pokryć wiele kombinacji wersji,
- w dokumentacji rośnie liczba wariantów jednego zasobu.
Taki micro-versioning bywa sensowny:
- w API gRPC/GraphQL, gdzie wersja jest mocno związana ze schematem (proto/schema) a nie z URL,
- w wewnętrznych integracjach, gdzie dokładnie wiadomo, który klient używa której wersji metody.
Dla klasycznego publicznego REST-a to zwykle overkill. Częściej sprawdza się jeden numer wersji na całą „rodzinę” zasobów, plus ewolucja pola po polu w ramach tej wersji.
Warstwa tłumacząca (adapter) między domeną a wersją API
Niezależnie od wybranego modelu wersjonowania (URL, nagłówki, micro-versioning), duża część bólu bierze się z mieszania:
- modelu domenowego (jak dane są przechowywane i liczone w środku),
- modelu kontraktu API (jak dane są widoczne dla klientów).
Dobry wzorzec to wyraźna warstwa adapterów:
- wewnątrz serwisu masz „czysty” model domenowy – klasy, encje, value objecty, które ewoluują bez patrzenia na historię API,
- na brzegu (controllers/handlers) masz mapery: domain → DTO v1, domain → DTO v2,
- serwis biznesowy nie wie, czy obsługuje
/v1czy/v2– dostaje ujednolicony model wejścia.
Mechanicznie wygląda to często tak:
- endpoint
/v1/ordersprzyjmujeOrderRequestV1, mapuje go naCreateOrderCommand, - endpoint
/v2/ordersprzyjmujeOrderRequestV2, też mapuje naCreateOrderCommand, - serce systemu widzi tylko
CreateOrderCommandiOrder.
Dzięki temu:
- „śmieci historyczne” zostają na krawędzi API – w starych DTO i mapperach,
- nowe wymagania biznesowe realizujesz raz, niezależnie od wersji,
- deprecjacja wersji sprowadza się do usunięcia konkretnych adapterów i endpointów, nie przepisywania logiki.
Strategie wersjonowania, które nie prowadzą do lawiny v1, v2, v3
Explicit policy: kiedy wolno zrobić nowe /v2
Jednym z najprostszych mechanizmów obronnych jest spisanie jasnych zasad „kiedy robimy nową wersję API”. Bez tego każde niewygodne pole kusi, żeby „rzucić nową v-kę i mieć spokój”.
Przykładowy zestaw kryteriów dla nowego /v2:
- zmienia się model autoryzacji / bezpieczeństwa (np. nowy sposób delegacji uprawnień),
- fundamentalnie zmienia się model danych (np. z „adres w zamówieniu” na „osobny zasób Address”),
- stary kontrakt zawiera błąd semantyczny, którego nie da się naprawić non-breaking (np. pole o mylącej nazwie, które masowo używają klienci),
- chcesz uciąć historyczny dług: legacy pola, hacki, niejednoznaczne statusy odpowiedzi.
I przeciw-kryteria, kiedy nie robisz nowej wersji:
- chcesz dodać nowe opcjonalne pole, którego stare klienty nie znają,
- chcesz doprecyzować dokumentację lub dodać nowy błąd HTTP, ale stary przypadek nadal działa,
- masz błąd, który da się naprawić zachowując poprzedni kształt odpowiedzi.
Uwaga: dobrze, jeśli taka polityka jest wspólna dla całej organizacji, nie tylko jednego serwisu. Inaczej każdy zespół zacznie interpretować „kiedy v2” po swojemu.
Cykl życia wersji: GA, deprecjacja, EOL
Jeżeli już wprowadzasz /v2, trzeba od razu zaplanować jej pełny cykl życia. Pomaga podejście z trzema etapami:
- GA (General Availability) – wersja jest oficjalnie wspierana i rekomendowana dla nowych klientów.
- Deprecated – wersja nadal działa, ale nie jest rozwijana; klienci są zachęcani do migracji.
- EOL (End of Life) – wersja wyłączona; wywołania zwracają kontrolowany błąd (np. 410 Gone) lub są przekierowane do wersji fallback.
W praktyce potrzebujesz do tego:
- polityki SLA – jak długo po oznaczeniu jako deprecated wersja jeszcze działa,
- komunikacji – changelog, maile do developerów, bannery w dokumentacji,
- monitoringu – dashboard z realnymi wywołaniami per wersja.
Bez tego każdy /v1 zostaje wieczny „na wszelki wypadek”, a później nikt nie ma odwagi go wyłączyć, bo „może coś istotnego padnie”.
Feature toggles i rollout zamiast twardej v2
Nie każda zmiana, która może złamać kontrakt, wymaga nowego /v2. Część konfliktów da się rozwiązać przez:
- feature flagi – tymczasowe włączanie nowego zachowania tylko dla wybranych klientów,
- canary rollout – mały procent ruchu dostaje nową wersję odpowiedzi, reszta starą,
- parametry zapytania typu
?includeExperimentalField=true.
Schemat jest prosty:
- backend implementuje nową ścieżkę logiki równolegle ze starą,
- frontend lub mobilka „opty-inuje się” w nową wersję, np. specjalnym nagłówkiem,
- po pewnym czasie, kiedy wszyscy klienci korzystają już z nowego zachowania, stara gałąź jest czyszczona.
To nadal jest wersjonowanie, tylko „miękkie”. Działa szczególnie dobrze w wewnętrznych integracjach i tam, gdzie masz kontrolę nad wszystkimi klientami. Publiczne API z setką nieznanych integracji wymaga ostrożniejszego podejścia – tu feature flagi nie zwalniają z jasnej polityki wersji.
Kontrakt jako produkt, nie artefakt
Jeżeli API ma nie blokować frontendu i mobilki, jego wersjonowanie trzeba traktować jak rozwój produktu, a nie przypadkowy efekt uboczny deployów. Kilka prostych praktyk robi różnicę:
- każda zmiana w kontrakcie ma swojego „właściciela biznesowego” – kogoś, kto rozumie skutki dla klientów,
- istnieje changelog kontraktu (np. generowany z OpenAPI + dopiski ręczne),
- najpierw pojawia się kontrakt (propozycja schematu), potem implementacja – nie odwrotnie.
Wtedy naturalnie powstają „wydania” kontraktu (np. 1.0, 1.1 w dokumentacji), ale nie muszą się one pokrywać z /v1, /v2 w URL. Zespół klienta patrzy na changelog i sam decyduje, kiedy zaktualizować SDK czy parser, zamiast reagować na losowe 500-tki w produkcji.
Wersjonowanie w REST: konkretne wzorce i pułapki
Dodawanie pól i „tolerant reader” po stronie klienta
Najtańszą zmianą w REST-owym JSON-ie jest dodanie nowego pola. Warunek: klienci są napisani w stylu „tolerant reader” (czytelnik tolerancyjny), czyli:
- ignorują nieznane pola w odpowiedzi,
- nie polegają na pełnym matchu schematu (strict schema),
- nie traktują brakującego pola jako katastrofy, jeśli mają sensowny default.
W praktyce oznacza to kilka technicznych nawyków:
- w JSON nie używasz walidatorów typu „reject unknown” po stronie klienta produkcyjnego (mogą być w testach!),
- nie iterujesz po polach obiektu „po kolei”, tylko sięgasz po to, co rzeczywiście potrzebne,
- serializery/deserializery są ustawione tak, by spokojnie przechodzić nad nieznanymi właściwościami.
Jeżeli ten wzorzec jest konsekwentnie stosowany, ogromna część rozwoju API to tylko dodawanie pól i endpointów. Frontend czy mobilka same wybiorą, kiedy wykorzystać nowe informacje – backend nie musi dla nich robić nowej wersji.
Unikanie „magicznych” zmian domyślnego zachowania
Jedna z bardziej zdradliwych zmian w REST to zmiana domyślnych filtrów, sortowania czy paginacji. Z punktu widzenia backendu nic „oficjalnie” się nie złamało – status 200, payload wygląda podobnie. Tymczasem frontend dostaje inny zestaw danych i jego logika zaczyna się mylić.
Bezpieczniejszy wzorzec:
- nowe zachowanie wprowadzasz pod nowym, jawnie nazwanym parametrem (np.
sort=newestzamiast zmieniać default), - domyślne zachowanie ogłaszasz w dokumentacji jako „stabilne” i unikasz jego modyfikacji bez deprecjacji,
- jeśli już musisz zmienić default, robisz to w nowej wersji API lub po okresie, w którym klient może się opt-in/out.
Naming i semantyka pól a przyszłe wersje
Czysto techniczny JSON z kiepskimi nazwami szybko staje się blokadą. Przykład z życia: pole status, które „na początku” było tylko PENDING i DONE. Pięć sprintów później trzeba dodać CANCELLED, FAILED i PARTIAL. Frontend miał wcześniej zrobione ify „status != DONE → pokaż spinner”. Niby żadna zmiana w kontrakcie, ale UX kompletnie się rozjeżdża.
Kilka prostych zasad nazw i enumów:
- unikaj nazw „tymczasowych” (np.
tempFlag,newField) – zostaną z tobą na lata, - jeżeli enum będzie rozszerzany, klient nie powinien używać
elsetypu „inaczej błąd”, tylko mieć sensowny fallback, - jeżeli statusy mają różne klasy (np. „terminalne”, „przejściowe”), rozważ dwa pola:
statusilifecycleStage.
Dobrze działa też jawne rozdzielenie pól „stabilnych” od tych, które są eksperymentalne lub biznesowo chwiejne. Można to zrobić przez osobne obiekty (np. metadata.experimental.*) albo wyraźne oznaczenia w dokumentacji. Klienci uczą się, że na stabilnych polach budują główną logikę, a na reszcie – tylko „cukier” w UI. W razie przyszłej zmiany zakres wstrząsu jest wtedy przewidywalny.
Jeśli spodziewasz się, że jakaś struktura będzie ewoluować (np. ceny, rabaty, podatki), lepiej od razu zamknąć ją w osobnym obiekcie niż rozlewać po płaskim JSON-ie. Zamiana:
{
"price": 100,
"discount": 10
}
na:
{
"pricing": {
"base": 100,
"discount": 10
}
}
pozwala później dodać walutę, typ rabatu czy źródło wyceny bez rozbijania istniejącego kontraktu. Klient, który dziś korzysta tylko z pricing.base, jutro może bez bólu zaadaptować pricing.currency albo pricing.breakdown.
Uwaga praktyczna: nazwy pól są tak samo częścią kontraktu jak typy. Zmiana semantyki createdAt z „utworzenia rekordu” na „utworzenia zamówienia w systemie zewnętrznym” to breaking change, nawet jeśli format ISO daty zostaje ten sam. Jeżeli musisz dokonać takiego przesunięcia znaczenia, lepiej dodać nową właściwość (np. externalCreatedAt) i jasno opisać różnicę niż „po cichu” przepiąć stare pole.
Wersjonowanie błędów i kodów odpowiedzi
Modele danych to połowa kontraktu. Druga połowa to błędy. Źle „wersjonowane” błędy potrafią unieruchomić front lub apkę równie skutecznie jak zmiana payloadu.
Kilka zasad, które upraszczają życie:
- stały format błędu – jeden kształt odpowiedzi błędów (np.
{ "code": "...", "message": "...", "details": {...} }) dla całego API, niezależnie od wersji endpointu, - stabilne kody biznesowe – pole
codejest częścią kontraktu (np.USER_NOT_FOUND,CARD_EXPIRED), - wiadomości jako „cukier” –
messagema być dla ludzi, można ją zmieniać bez traktowania tego jako breaking change, - status HTTP to nie wszystko –
400kontra422to detal; frontend i mobilka bazują na kodach biznesowych, nie samej liczbie.
Jeśli potrzebujesz nowego wariantu błędu, lepiej dodać nowy kod (np. CARD_LIMIT_EXCEEDED) niż przepisać semantykę istniejącego CARD_DECLINED. Stare klienty nadal reagują na dotychczasowe kody, nowe stopniowo przechodzą na precyzyjniejsze rozróżnienia.
Dobry wzorzec to też tabela kompatybilności błędów w dokumentacji: które kody mogą się pojawić na danym endpointcie w danej wersji. Mobilka może wtedy jasno zmapować je na swoje stany UI (np. „pokaż ekran KYC”, „pokaż dialog 3DS”), zamiast utrzymywać voodoo listę „magicznych stringów”.
Struktury paginacji, sortowania i filtrów
Paginacja to klasyczny obszar, gdzie pojawia się pokusa „v2”, bo product owner wymyślił nowy sposób przeglądania list. Zamiast skakać w nowe ścieżki, lepiej od razu mieć stabilny model, który wytrzyma kilka iteracji.
Dobrym punktem wyjścia jest:
- jawne pola
page/pageSizelubcursor– bez domyślnych „magicznych” limitów, - sekcja
metaz informacjami o paginacji w odpowiedzi (np.total,hasNext,nextCursor), - od początku przewidziany parametr
sorti opcjonalnieorder(np.sort=createdAt&order=desc).
Jeśli po pół roku potrzebujesz więcej logiki (np. paginacja po dacie zamiast po ID), zamiast wprowadzać /v2/items możesz:
- dodać nowy tryb, np.
paginationMode=time, - dodać nowy typ kursora (np.
cursor=ts:1680000000), - wprowadzić nową wartość
sort, zostawiając stare zachowanie jako domyślne.
Warunek jest ten sam, co wcześniej: nie zmieniasz domyślnego zachowania dla istniejących wywołań bez okresu przejściowego. Frontend i mobilka powinny się same „zapisać” na nowy tryb paginacji.
Kompatybilność JSON vs. ewolucja typów
JSON udaje, że typy są proste: string, liczba, boolean, obiekt, tablica. W praktyce problemem są typy biznesowe (kwoty, daty, identyfikatory), które z czasem się zmieniają.
Kilka częstych pułapek:
- przestawienie formatu daty z
yyyy-MM-ddna pełen ISO 8601, - zmiana typu liczby z
intnadecimal, - „inteligentne” ID, które nagle przestają być tylko liczbą.
Lepsze podejście:
- od początku używaj stringów dla dat i ID (łatwiej je ewoluować w czasie),
- kwoty trzymaj jako string lub centy (np.
amountCents), a nie float, - jeśli musisz zmienić typ, wprowadź nowe pole (np.
amount→amountDecimal): stare oznacz jako deprecated.
Tip: testy kontraktowe po stronie klienta powinny pilnować nie tylko obecności pól, ale też ich typu i podstawowych założeń (np. „createdAt jest zawsze datą w przeszłości”). Łatwiej wtedy złapać przypadkową zmianę w backendzie, zanim pójdzie w świat.
API „read-only” a API „command” – dwa różne rytmy wersji
Nie wszystkie endpointy są równe. Inaczej zachowuje się API do czytania danych (listy, szczegóły), inaczej API do wykonywania operacji (tworzenie zamówienia, wykonanie przelewu). Rytm wersjonowania obu typów bywa zupełnie inny.
Dla endpointów read-only:
- bardzo dużo można załatwić poprzez dodawanie pól i filtrów,
- ryzyko „zepsucia świata” jest niższe: najwyżej frontend pokaże mniej idealne dane,
- częściej opłaca się rozwijać w ramach jednej wersji i unikać
/v2.
Dla endpointów command (mutacje):
- zwykle masz wysoką odpowiedzialność biznesową (płatności, workflowy),
- kontrakt bywa mocno związany z procesami w innych systemach,
- zmiany kształtu żądań i odpowiedzi szybciej kwalifikują się do osobnych wersji (np.
/payments/v2/charge).
Praktyczny trick: endpointy read-only staraj się utrzymywać „wieczne” (tylko dodatki), a nowe wersje wprowadzaj głównie dla mutacji, gdzie zmiany procesów są nieuniknione. To ogranicza liczbę miejsc, w których frontend/mobilka muszą skakać między wersjami.
Wersjonowanie API a mobile: cykle releasów, store’y i offline
Spięcie cyklu releasów backendu z releasem aplikacji
Backend możesz wdrożyć dziś wieczorem. Aplikacja mobilna będzie na produkcji za tydzień, dwa, czasem miesiąc. Do tego dochodzą użytkownicy, którzy latami nie aktualizują aplikacji. Wersjonowanie API musi to uwzględniać.
Podstawowy model:
- nowe funkcje backendu są wprowadzane w sposób backward compatible,
- aplikacja mobilna stopniowo zaczyna z nich korzystać, bazując na feature flagach i parametrach capabilities,
- stare wersje aplikacji działają poprawnie na „starym wycinku” kontraktu przez określony czas.
Unikaj scenariusza „wypuszczamy nowy backend razem z apką”. Mobile rzadko ma taki luksus, bo rollout w sklepie jest rozciągnięty w czasie, a użytkownicy aktualizują się losowo.
Wsparcie wielu wersji aplikacji – ile to ma sens
Pytanie, które szybko wraca: jak długo wspierać starą wersję aplikacji w kontekście API? Odpowiedź zwykle jest kombinacją:
- polityki biznesowej (np. wymuszamy aktualizację co 6 miesięcy),
- telemetrii (ile procent ruchu pochodzi ze starych wersji),
- kosztu utrzymania kompatybilności po stronie backendu.
Praktyczny wzorzec:
- backend identyfikuje wersję aplikacji (nagłówek
X-App-Versionlub numer w JWT), - na tej podstawie wymusza minimalną wersję API (np. aplikacja <= 3.1 może uderzać tylko w pewien zakres ścieżek i parametrów),
- po okresie
grace period– stara wersja aplikacji dostaje kontrolowaną odpowiedź zachęcającą do aktualizacji (np. kod błęduAPP_VERSION_UNSUPPORTED+ link do sklepu).
To nie jest „hard break” w stylu 500-tki. To świadome zamknięcie ruchu z bardzo starych buildów, które blokują dalszy rozwój kontraktu.
Capabilites: ustalanie, co aplikacja potrafi zjeść
Zamiast dopasowywać backend do wszystkich historycznych wersji aplikacji, lepiej odwrócić relację: niech aplikacja informuje backend, na co jest gotowa. Ten mechanizm często nazywa się capabilities.
Prosty przykład:
GET /offers
X-App-Version: 4.2.0
X-App-Capabilities: pricing_v2, coupons, one_click
A backend odpowiada:
- jeśli aplikacja potrafi obsłużyć
pricing_v2– zwraca nowe pola, - w przeciwnym razie – zostaje przy starszej, uproszczonej strukturze.
Lista capabilities to w zasadzie „miękkie wersjonowanie” funkcji API. Pozwala:
- wdrożyć backend wcześniej, przed wypuszczeniem aplikacji,
- odciąć starsze buildy od półeksperymentalnych funkcji (nie dostaną pola, którego nie znają),
- prowadzić testy A/B per capability, nie per wersja endpointu.
Kontrakty pod offline i cache po stronie mobilki
Mobilka ma jeszcze jeden wymiar: offline. Kontrakt API musi brać pod uwagę to, że dane są cache’owane i używane po godzinach od pobrania. Zbyt agresywne zmiany w strukturze odpowiedzi potrafią rozjechać offline’owy scenariusz bez widocznego błędu HTTP.
Kilka praktycznych reguł:
- unikaj szybkiego usuwania pól, z których mobilka może robić cache (np. nazwy, ceny, małe słowniki),
- jeżeli zmieniasz semantykę pola, wprowadź nowe (np.
status→orderStatus), a stare zachowaj na chwilę jako „pomost”, - jeśli pole staje się krytyczne dla logiki offline (np. flaga „czy dane są kompletne”), zadbaj, żeby jego brak nie wywalił całego ekranu – mobilka powinna mieć fallback.
Często opłaca się utrzymywać osobny, bardzo stabilny „offline contract” – nawet jeśli nie jest on formalnie inną wersją API, to pewien podzbiór pól oznaczony jako strategiczny dla cache’u. Zmiana w tym obszarze powinna przechodzić taki sam proces jak wydanie nowej wersji aplikacji.
Migracje danych między wersjami aplikacji
Aplikacja mobilna ma swoje lokalne modele (baza, storage). Kiedy kontrakt API się zmienia, lokalne schematy też się zmieniają. Jeśli backend agresywnie usuwa pola, a migracje w aplikacji nie nadążają, kończy się na krashach przy odczycie starych danych.
Lepszy schemat współpracy:
- backend ogłasza deprecjację pola z odpowiednim wyprzedzeniem,
- mobilka wypuszcza wersję z migracją lokalnych danych (np. uzupełnienie nowego pola na podstawie starego),
- dopiero po osiągnięciu sensownego zasięgu nowej wersji aplikacji – backend usuwa pole na dobre.
Da się to sformalizować: w changelogu API każde breaking usunięcie pola ma datę, od której backend gwarantuje, że minimum X% ruchu z mobilki pochodzi z wersji z odpowiednią migracją. Decyzję o „X” podejmuje biznes, ale mechanizm jest techniczny.

Jak nie blokować frontendu: kontrakt‑first, BFF i testy kontraktowe
Kontrakt‑first: najpierw schemat, potem implementacja
Kontrakt‑first nie oznacza kartonowej architektury i tygodni na UML-e. Chodzi o prostą dyscyplinę:
- ustalenie schematu (np. OpenAPI/Swagger, JSON Schema, Protobuf) przed implementacją,
- przegląd kontraktu z zespołem frontu/mobilki,
- dopiero potem pisanie kodu backendu.
Korzyści pod kątem wersjonowania:
- sporne zmiany wychodzą w review, a nie w produkcji,
- łatwiej ocenić, czy coś jest breaking, bo widać diff schematu,
- da się generować stuby/SDK dla frontu i mobilki zanim backend będzie gotowy (mocki na podstawie schematu).
Uwaga: „kontrakt” to nie tylko JSON. To także zasady paginacji, sortowania, polityka błędów, SLA. Dobrze, jeśli to wszystko jest opisane w jednym repozytorium z definicjami API, a nie rozrzucone po Confluence’ach.
BFF (Backend For Frontend) jako amortyzator wersji
Backend For Frontend (BFF) to cienka warstwa między „systemami core” a konkretnymi klientami (web, mobile). W kontekście wersjonowania pełni rolę adaptera, który osłania frontend przed częścią zmian w core’owym API.
Typowy układ:
- core API ewoluuje w swoim tempie (częściej, z większą ilością szczegółów domenowych),
- BFF udostępnia stabilny, uproszczony kontrakt dostosowany do potrzeb UI,
- frontend „zna” tylko BFF, więc rzadziej odczuwa zmiany w core’ze.
Kluczowe jest to, gdzie kończy się odpowiedzialność BFF. Jeżeli BFF zaczyna odtwarzać całą logikę biznesową core’u, szybko zamienia się w „drugi backend”, który także trzeba wersjonować i utrzymywać. Lepiej, żeby pełnił trzy konkretne role: agregował dane pod potrzeby ekranu, tłumaczył kontrakt core’owych usług na kontrakt UI oraz amortyzował zmiany typu „zmieniamy nazwy pól” czy „łączymy dwa endpointy w jeden”. Dzięki temu core może zmieniać się bardziej agresywnie, a BFF zapewnia dla frontu wrażenie stabilności.
Dobry BFF ma swój lifecycle wersji, ale lżejszy niż core. Zamiast globalnych /v1, częściej stosuje się drobne zmiany kontraktu i krótki, kontrolowany okres współistnienia starego i nowego kształtu odpowiedzi. Przykład: BFF przez kilka tygodni obsługuje jednocześnie GET /orders w starym i nowym formacie, rozpoznając, który kontrakt zje dany build aplikacji (np. po nagłówku lub capability). Gdy nowa wersja frontu osiągnie wystarczający zasięg – stary kod jest usuwany bez dotykania core’owego API.
BFF bardzo ułatwia selektywne „odcinanie” legacy frontów. Można w nim zaimplementować logikę: „dla aplikacji < 3.0 nie pokazuj nowych typów ofert”, „dla przeglądarek bez konkretnego feature’u frontendu serwuj uproszczoną reprezentację”. To wciąż to samo API z perspektywy core’u, ale warstwa BFF wprowadza miękkie wersjonowanie pod konkretnego klienta. Mniej wersji w core, więcej elastyczności na brzegu systemu.
Testy kontraktowe: automat, który pilnuje kompatybilności
Nawet najlepsze ustalenia kontraktowe nic nie dadzą, jeśli ktoś może „przypadkiem” złamać kontrakt przy refaktorze. Tu wchodzi mechanizm testów kontraktowych (consumer‑driven contracts). Zamiast polegać na ręcznych testach regresji, klienci API (frontend, mobilka, BFF) dostarczają do repozytorium backendu własne kontrakty oczekiwań wobec API, a CI/CD pilnuje, żeby kolejne zmiany implementacji tych oczekiwań nie łamały.
Praktyczny schemat wygląda tak: frontend ma zestaw scenariuszy (np. w Pact, Spring Cloud Contract, Hoverfly), które opisują oczekiwane requesty/response’y dla konkretnych use case’ów. Backend w swoim pipeline uruchamia te scenariusze na każdej gałęzi. Jeśli ktoś usunie pole, zmieni typ albo status HTTP, którego frontend faktycznie używa – build backendu nie przechodzi. Taki „bezlitosny” feedback loop bardzo skutecznie ogranicza przypadkowe breaking changes.
Testy kontraktowe nie zastępują testów E2E, ale w kontekście wersjonowania API robią coś ważnego: formalizują to, z czego klienci naprawdę korzystają. Jeśli pole istnieje w schemacie, ale nikt nie ma do niego kontraktu jako konsument – jego usunięcie jest dużo mniej ryzykowne. Zamiast emocjonalnych dyskusji „kto tego używa”, są twarde artefakty w repo. Do tego łatwo powiązać deprecjacje w API z usuwaniem konkretnych kontraktów po stronie klienta.
Dobrze zaprojektowane wersjonowanie API nie sprowadza się więc do wzoru na numerki wersji, tylko do zestawu nawyków: małych, kontrolowanych zmian, przewidywalnej deprecjacji, czytelnego kontraktu i automatycznych strażników w pipeline’ach. Jeśli backend, frontend i mobilka grają w jednej drużynie i dzielą się odpowiedzialnością za kontrakt, wersje przestają być przeszkodą, a stają się zwykłym narzędziem do bezpiecznej ewolucji produktu.
Proces i narzędzia: jak zorganizować pracę z wersjami API w zespole
Źródło prawdy: repozytorium kontraktów
Kontrakt API musi mieć jedno, wspólne źródło prawdy. Najczęściej jest to osobne repozytorium z definicjami OpenAPI/Protobuf/JSON Schema wraz z changelogiem. To repo nie jest „dokumentacją”, tylko artefaktem inżynierskim, z którym pracuje backend, frontend i mobilka.
Praktyczny układ plików:
/spec/core/...– kontrakty usług domenowych (płatności, katalog, konto),/spec/bff/...– kontrakty BFF-ów,/spec/shared/...– wspólne typy (np.Money,Error,Pagination),/changelog.mdalbo/docs/changelog/*.md– historia zmian z oznaczeniem breaking/non‑breaking.
Ważne, żeby backend nie trzymał lokalnej kopii specyfikacji, tylko konsumował ją jako dependency (np. przez submodule, git subtree, paczkę NPM/Maven). Inaczej wersje kontraktu i implementacji zaczną się rozjeżdżać – a wtedy żadne testy kontraktowe nie pomogą.
Flow zmian: od propozycji do wdrożenia
Dobrze ustawiony proces zmian kontraktu mocno ogranicza chaos z v1, v2, v3. Minimalny, ale działający schemat:
- Proposal – ktoś z backendu/frontu/mobilki wrzuca MR/PR do repo kontraktów, opisuje motywację, oznacza zmiany jako breaking lub nie.
- Review – uczestniczą obie strony: autor kontraktu (zwykle backend) i konsumenci (web/mobile/BFF). Na tym etapie często wychodzą pomysły typu „zamiast nowego endpointu wystarczy nowe pole i capability”.
- Merge + release kontraktu – po akceptacji generowany jest tag wersji kontraktu (np.
orders-api@1.8.0) i automatycznie publikowane są klienty (SDK, stuby) do rejestru pakietów. - Implementacja backendu – backend wciąga nową wersję kontraktu jako dependency i implementuje ją, pilnując zielonych testów kontraktowych.
- Implementacja klienta – frontend/mobilka podbijają wersję klienta, robią adaptacje UI, uruchamiają swoje testy kontraktowe i E2E.
Kluczowy trik: deploy backendu z nową wersją kontraktu zwykle musi nastąpić przed pełnym rolloutem klienta. Stąd wszystkie mechanizmy miękkiego wersjonowania: capabilities, feature flagi, backward compatible zmiany.
Polityka wersji: semver, ale z doprecyzowaniem
Semantic Versioning (semver) ma sens, ale trzeba go dociąć do realiów API. Dobrze działa prosty zestaw zasad:
MAJOR– dopiero gdy naprawdę kasujesz cały kontrakt (np. przejście z REST na gRPC dla danej domeny) albo zmieniasz model danych nie do ogarnięcia adapterem/BFF,MINOR– nowe pola, nowe endpointy, nowe eventy, zmiany backward compatible,PATCH– poprawki błędów w specyfikacji, doprecyzowanie opisów, realignment z implementacją.
Żeby nie skończyć z v7 wszystkiego, część zespołów w ogóle zabrania bumpowania MAJOR dla pojedynczych endpointów REST. Zamiast /orders/v2 powstaje nowe „narrow API” pod inną ścieżką (/order-summaries), a stary kontrakt jest sukcesywnie wygaszany. Z punktu widzenia repo kontraktów nadal można mieć wersję orders-api@2.x, ale nie przekłada się to 1:1 na URL.
Deprecjacje: tablica czasowa, nie „zobaczymy”
Większość „martwych” wersji API bierze się z tego, że ktoś zapowiedział deprecjację, a potem nie dowiózł usunięcia. Tu pomaga prosta, ale egzekwowana procedura:
- każda deprecjacja w kontrakcie ma trzy daty: ogłoszenia, końca wsparcia soft (warningi, logi, komunikaty) i końca wsparcia hard (faktyczne usunięcie),
- te daty żyją w jednym, maszynowo czytelnym miejscu (np.
deprecations.yaml), - CI/CD ma job, który alarmuje, gdy zbliża się hard deadline, a kod nadal istnieje,
- dashboard produktowo‑techniczny pokazuje, ile ruchu wciąż używa deprecated kontraktu (z bazy logów/APM).
Dopiero przy takim podejściu można uczciwie powiedzieć: „v1 naprawdę znika tego i tego dnia” – i backend nie trzyma zombie endpointów latami tylko dlatego, że „może ktoś z nich korzysta”.
Bezpieczna ewolucja kontraktu: techniki minimalizowania breaking changes
Rozszerzanie zamiast modyfikowania
Najmniej problematyczne zmiany to takie, które tylko doklejają coś do istniejącego kontraktu:
- dodanie nowego pola nieobowiązkowego,
- dodanie nowej wartości enum, jeśli konsumenci potrafią obsłużyć „nieznane” (np. jako
UNKNOWN), - dodanie nowego parametru query z domyślną wartością, która odwzorowuje dotychczasowe zachowanie.
Tip: zamiast zmieniać typ pola z prostego na złożony, lepiej dodać nowe. Przykład:
{
"price": 123.45,
"priceDetailed": {
"amount": "123.45",
"currency": "PLN",
"precision": 2
}
}
Frontend/mobilka przechodzą powoli na priceDetailed, a price znika dopiero wtedy, gdy pewność co do migracji jest wysoka. Tego typu „dublety” bywają irytujące w kodzie, ale ogromnie ułatwiają bezbolesne przejścia.
Fałszywe breaking changes
Część potencjalnie breaking zmian da się rozwiązać sprytniej:
- Zmiana nazwy pola – zamiast rename, wprowadź alias: backend akceptuje zarówno
oldName, jak inewNamew requestach, w response’ach przez jakiś czas wysyła oba (albo steruje tym capability). - Zmiana znaczenia wartości – dodaj nowe wartości enum (np.
SHIPPED→DISPATCHED,DELIVERED), stare mapuj w backendzie na nowe, ale jeszcze przez jakiś czas je emituj z flagą deprecjacji. - Zmiana sposobu paginacji – zamiast od razu przechodzić z
page/sizenacursor, obsłuż oba podejścia przez transition period i ustaw domyślnie stare zachowanie, dopóki klienci się nie przełączą.
Takie kompromisy wydłużają czas, w którym kod jest brzydszy, ale skracają czas, w którym zespół frontu/mobilki jest zablokowany. Z punktu widzenia tempa rozwoju produktu to się zwykle opłaca.
Feature flagi na poziomie kontraktu
Feature flagi kojarzą się z UI, ale świetnie działają też na poziomie API. Kilka typowych use case’ów:
- włączenie nowego pola tylko dla kont testowych/QA,
- udostępnienie nowego endpointu tylko dla określonego środowiska (np.
beta), - stopniowe rozszerzanie nowego kontraktu na kolejne grupy użytkowników (np. 5% ruchu, 20%, 50%).
W praktyce sprowadza się to do:
- flagi w konfiguracji backendu (np. LaunchDarkly, Unleash, homemade),
- oznaczenia w kontrakcie, że dane pole/endpoint jest „guarded by <FLAG_NAME>”,
- logiki w kodzie, która dla części requestów wysyła stary, a dla części nowy kształt odpowiedzi.
Jeśli front/mobilka dogadają się z backendem, że nowy kontrakt będzie na początku chroniony flagą, mogą szybciej wypuścić kod kliencki, wiedząc, że backend nie włączy przełącznika zanim nie skończą implementacji.
Monitorowanie i obserwowalność wersji API
Tagowanie wersji i capabilities w logach
Bez sensownej obserwowalności dyskusje o usuwaniu wersji API są czystą spekulacją. Logi i metryki muszą wyraźnie znać:
- wersję aplikacji (web build, mobile build),
- wersję kontraktu (np.
X-Api-Contract: orders@1.7.0), - aktywny zestaw capabilities (
X-App-Capabilities).
To powinno być widoczne:
- w logach requestów (np. w Elasticsearch, Loki),
- w metrykach (Prometheus/Grafana) – liczba requestów per wersja klienta i capability,
- w APM (Datadog, New Relic) – trace’y z tagiem wersji.
Dzięki temu pytanie „czy możemy ubić GET /orders v1?” zamienia się na proste zapytanie: „jaki procent requestów na ten endpoint pochodzi jeszcze z wersji aplikacji < X i bez capability orders_v2?”.
Alerty na niekompatybilność
Dobrym zabezpieczeniem jest automatyczne wykrywanie „dziwnych” błędów, które zwykle oznaczają złamany kontrakt:
- nagły spike odpowiedzi 4xx/5xx tylko dla konkretnej wersji aplikacji,
- częstsze time‑outy na jednym, świeżo zmienionym endpointzie,
- wzrost liczby błędów deserializacji po stronie BFF/frontu (np. w Sentry, Firebase Crashlytics).
Zestaw prostych reguł alertów plus dashboard „Contract health” pozwala szybko złapać sytuacje, w których ktoś mimo wszystko przemycił breaking change lub klient używa API w nieoczywisty sposób (np. pole, które miało być pomocnicze, stało się krytyczne dla logiki UI).
Organizacja zespołu a wersjonowanie API
Wspólne refinementy API
Wersjonowanie to nie tylko technika, ale też kwestia komunikacji. Zespoły, które izolują refinementy backendu od frontendu/mobilki, produkują więcej niepotrzebnych wersji. Znacznie lepiej sprawdza się praktyka:
- osobny slot w sprincie na „API refinement” z udziałem backendu, web, mobile, QA,
- omawianie zmian właśnie na poziomie kontraktów (diff specyfikacji), nie tylko user story,
- świadome decydowanie, które zmiany muszą być backward compatible, a które mogą być breaking z planem migracji.
Często podczas takiej sesji ktoś z frontu zauważy, że zamiast trzech nowych endpointów wystarczy jedno, rozszerzalne zapytanie z filtrami, a ktoś z mobilki zwróci uwagę na problem z offline lub rozmiarem odpowiedzi.
Ownerzy domen i „API guardians”
Gdy API rośnie, potrzebny jest czytelny podział odpowiedzialności. Dwa uzupełniające się role:
- owner domeny – odpowiada za logikę biznesową danego fragmentu (np. płatności, katalog) i dług technologiczny w implementacji,
- API guardian – pilnuje jakości i spójności kontraktu w skali całego produktu (nazewnictwo, style errorów, standardy paginacji, reużywalne typy).
Guardian nie jest architektem – raczej „linterem w ludzkiej skórze”. Zwykle ma prawo veta wobec kontraktów, które łamią ustalone standardy lub wciskają zbyt agresywne breaking changes bez planu migracji. Dzięki temu w repo kontraktów nie powstaje „muzeum pomysłów”, tylko spójny, ewoluujący zestaw API.
Kontrakty jako część Definition of Done
Definition of Done dla user story, które dotyka API, powinno uwzględniać kilka prostych punktów:
- zaktualizowany kontrakt (spec + changelog),
- przegląd kontraktu z konsumentami (web/mobile/BFF),
- testy kontraktowe po stronie backendu i klienta,
- deprecjacje (jeśli są) wpisane w system deprecjacji,
- metryki/alerty dostosowane do nowej funkcjonalności lub wersji.
Bez tego łatwo doprowadzić do sytuacji, w której backend deployment kończy się komunikatem do frontu: „a teraz szybko się dostosujcie, bo inaczej nie wypuścimy releasu”.
Strategie migracji wersji z perspektywy produktu
Big bang vs migracje stopniowe
Migracje API można robić na dwa skrajne sposoby:
- big bang – jednego dnia wszystkie klienckie aplikacje muszą obsługiwać nowy kontrakt, a stary znika,
- stopniowo – stary i nowy kontrakt współistnieją przez jakiś czas, a ruch jest sukcesywnie przełączany.
Big bang ma sens prawie wyłącznie w środowisku kontrolowanym (aplikacja wewnętrzna, jeden typ klienta, brak długiego życia starych buildów). W produktach konsumenckich i przy mobilce migracje stopniowe są praktycznie jedyną rozsądną drogą.
Typowy, zdrowy scenariusz:
- backend wystawia nowy kontrakt obok starego (nowy endpoint lub capability),
- front/mobilka implementują obsługę obu wersji i wypuszczają release,
- ruch jest stopniowo przełączany na nowy kontrakt (feature flagi, canary),
- stary kontrakt zostaje zamknięty dopiero wtedy, gdy metryki pokazują, że korzystają z niego już tylko nieistotne ogony.
Przy takim podejściu wymagające zmiany (np. nowy model płatności) da się wdrożyć bez „freeze’u” na frontendzie. Kluczowe jest to, żeby etap współistnienia kontraktów był zaplanowany jako normalny okres produkcyjny, a nie kilkudniowa „gimnastyka” między releasami.
Komunikacja deprecjacji z użytkownikami
Deprecjacja API dotyka nie tylko zespołów, ale często też klientów biznesowych i partnerów. Samo pole deprecated: true w OpenAPI to za mało. Przydatny jest prosty, powtarzalny kanał komunikacji:
- release notes z sekcją „API changes” i datami usunięcia konkretnych wersji,
- dedykowana strona „API changelog” z filtrem po domenach (np. „orders”, „payments”),
- automatyczne maile do partnerów/klientów integracyjnych, jeśli uderza ich konkretna deprecjacja.
W aplikacjach konsumenckich tę komunikację często przejmuje UI: baner w panelu administratora, komunikat w konsoli deweloperskiej (dla SPA), push w narzędziu do integracji. Im czytelniejsze sygnały, tym mniej „awaryjnych” utrzymywań martwych wersji API tylko dlatego, że ktoś przegapił zmianę.
Umowy SLA na cykle życia wersji
Przy większej liczbie konsumentów (zewnętrzni partnerzy, aplikacje white‑label) dobrze działa lekkie SLA (Service Level Agreement) na cykl życia wersji. To nie musi być prawniczy potwór, wystarczy ogólna zasada:
- minimalny czas wsparcia dla wersji (np. 6 lub 12 miesięcy od ogłoszenia deprecjacji),
- minimalny czas „heads‑up” przed wyłączeniem (np. 60 lub 90 dni),
- zasady dla krytycznych zmian bezpieczeństwa (możliwość skrócenia okresu, ale z intensywniejszą komunikacją).
Taka umowa przydaje się też wewnątrz organizacji. Front i mobile wiedzą, ile realnie mają czasu na migrację, produkt może planować roadmapę bez panicznych, jednorazowych skoków, a backend nie musi utrzymywać w nieskończoność prehistorycznych wersji tylko dlatego, że „ktoś kiedyś może jeszcze tego używać”.
API a strategia wsparcia starych aplikacji mobilnych
Mobilka jest szczególnie wrażliwa na długo żyjące buildy. Zespół produktu musi świadomie odpowiedzieć na pytanie: „jak długo wspieramy stare wersje aplikacji?”. Typowy, praktyczny model:
- backward compatible zmiany kontraktu są akceptowalne zawsze,
- breaking changes wymagają co najmniej jednego wydania aplikacji z migracją,
- aplikacje starsze niż N głównych wersji (np. N=2) mogą mieć gorsze doświadczenie (np. tryb ograniczonej funkcjonalności), ale nadal muszą działać w podstawowym scenariuszu.
Jeśli API jest zaprojektowane capability‑first, tryb „starej aplikacji” to w praktyce po prostu węższy zestaw capabilities. A to daje przewidywalny sposób na utrzymanie kompatybilności bez nieskończonej liczby wersji endpointów.
Dojrzałe wersjonowanie API to głównie rzemiosło: rozsądne kontrakty, stabilne reguły kompatybilności, kilka prostych narzędzi (specyfikacje, testy kontraktowe, feature flagi) i dyscyplina w obserwowalności. W takim setupie backend może się zmieniać szybko, a frontend i mobilka rozwijają produkt zamiast gasić pożary po kolejnym „niewinnym” breaking change’u.
Najczęściej zadawane pytania (FAQ)
Jak poprawnie wersjonować API, żeby nie blokować frontendu i aplikacji mobilnych?
Podstawą jest traktowanie API jak kontraktu i planowanie zmian z myślą o kompatybilności wstecznej (stary klient działa z nowym API). W praktyce oznacza to: dodawanie nowych pól zamiast zmiany istniejących, unikanie usuwania pól „z dnia na dzień” oraz jasną politykę deprecjacji (oznaczanie, że coś będzie wycofane i kiedy).
Dobrze sprawdza się rozdzielenie „core API” od warstwy BFF (Backend for Frontend) dla weba i mobilki. Dzięki temu zmiany specyficzne dla UI nie wymuszają globalnego /v2 całego API, tylko modyfikujesz kontrakt dedykowany danemu klientowi, z kontrolą nad tym, co jest breaking.
Kiedy naprawdę potrzebne jest /v2 API, a kiedy da się tego uniknąć?
Nowa „globalna” wersja (np. /v2) jest uzasadniona dopiero wtedy, gdy masz serię nieuniknionych zmian breaking w wielu zasobach jednocześnie: zmiany modelu domenowego, inne podejście do autoryzacji, całkowita przebudowa schematu danych. Pojedynczych zmian nie opłaca się przepychać przez nowe /v2 całego API.
W większości przypadków wystarczy wersjonowanie per zasób lub per endpoint (np. /orders/v2) albo kontraktowe: nowy zestaw pól w odpowiedzi, ale pod tym samym URL, z okresem przejściowym, w którym stara i nowa forma współistnieją. Uwaga: im mniejszy granulat wersjonowania, tym łatwiej utrzymać tempo zmian bez paraliżu klientów.
Jak projektować API, żeby zmiany były jak najczęściej kompatybilne wstecz?
Trzeba świadomie projektować kontrakt tak, żeby łatwo było go rozszerzać. Kilka praktyk:
- pola, które mogą się zmieniać, ustawiaj jako opcjonalne, z sensownymi wartościami domyślnymi,
- unikaj sztywnych enumów po stronie klienta – niech klient ignoruje nieznane wartości,
- nie przeciążaj jednego pola wieloma znaczeniami, tylko dodawaj nowe atrybuty, gdy zmienia się semantyka.
Tip: po stronie klienta stosuj „defensywne parsowanie” (ignorowanie nieznanych pól, radzenie sobie z brakiem nowych pól). Dzięki temu dodanie czegoś w JSON-ie po stronie backendu nie psuje starszych wersji aplikacji.
Jak wersjonować API pod aplikacje mobilne z długim cyklem aktualizacji?
Przy mobilce trzeba zakładać, że przez tygodnie (czasem miesiące) w naturze będą działać równolegle różne wersje aplikacji. Backend musi więc utrzymywać jednocześnie kilka wersji kontraktu i dopiero po określonym czasie odcinać najstarsze.
Praktycznie wygląda to tak: definiujesz minimalnie wspieraną wersję appki i komunikujesz to użytkownikom, planujesz okno migracji (np. 2–3 release’y mobilne) i dopiero po jego zakończeniu wycofujesz stare pola/endpointy. Uwaga: każda breaking change w API mobilnym powinna mieć plan migracji i datę „end of life” ustaloną z zespołem mobile.
Czym różni się kompatybilność wsteczna od kompatybilności do przodu w API?
Kompatybilność wsteczna (backward compatibility) oznacza, że stare wersje klientów działają na nowej wersji API. Przykład: backend dodał nowe opcjonalne pole w odpowiedzi, ale stare aplikacje po prostu go ignorują i wszystko działa.
Kompatybilność do przodu (forward compatibility) to sytuacja, w której nowy klient jest w stanie poradzić sobie ze starszą wersją API – np. jeśli nie dostanie nowych pól, użyje domyślnych wartości lub uproszczonej ścieżki logiki. To trudniejsze, ale bywa kluczowe, gdy nie masz pełnej kontroli nad kolejnością wdrożeń (np. rollout mobilki trwa kilka tygodni).
Jak uniknąć „wiecznego utrzymywania” starych wersji API?
Kluczowa jest formalna polityka deprecjacji i jej egzekwowanie. Każdą zmianę breaking oznaczasz jako deprecated (w dokumentacji, OpenAPI, logach, a czasem w nagłówkach odpowiedzi), przypisujesz jej datę wyłączenia, a potem monitorujesz realny ruch na starych endpointach.
Dobry proces obejmuje:
- komunikat do zespołów frontend/mobile z konkretną datą graniczną,
- metryki i alerty na użycie deprecated endpointów,
- twarde usunięcie kontraktu po wygaśnięciu terminu – inaczej dług techniczny będzie tylko rósł.
Tip: w logach dodawaj wyraźny tag (np. api.deprecated=true) – ułatwia to negocjacje z biznesem, gdy trzeba coś w końcu wyłączyć.
Czy w API warto stosować semantyczne wersjonowanie (SemVer)?
SemVer (MAJOR.MINOR.PATCH) działa dobrze jako model myślowy, ale nie zawsze jako literalny schemat wersji w URL. Ma sens jako wersja kontraktu w dokumentacji czy w nagłówkach (np. X-API-Version: 2.3.0), gdzie: MAJOR = breaking changes, MINOR = nowe pola/endpointy, PATCH = naprawy bez zmian kontraktu.
W samym REST-owym URL-u częściej sprawdza się prostsze podejście: v1, v2 na poziomie API lub zasobu, a szczegółową wersję SemVer trzyma się w OpenAPI oraz changelogu. Dzięki temu nie kończysz z kuriozalnymi ścieżkami typu /api/v1.12.3, a nadal masz jasną informację, jaki kontrakt obowiązuje.






