Od pomysłu do produkcji: architektura, która nie zawiedzie pod obciążeniem
Jak prowadzić projekt od pierwszej decyzji architektonicznej po stabilne, monitorowane wdrożenie — bez przeskakiwania od razu do kodu.
Większość problemów produkcyjnych nie bierze się z braku umiejętności programistycznych. Bierze się z decyzji podjętych zbyt wcześnie, zbyt późno albo w ogóle. Dobra architektura to nie diagram — to ciąg świadomych wyborów, z których każdy da się uzasadnić i cofnąć, jeśli okaże się błędny.
Poniżej opisuję sposób, w jaki prowadzę projekt od pierwszej rozmowy po działającą produkcję. To nie jest przepis na sprzedaż — to metodyka, która ogranicza ryzyko.
Zacznij od ograniczeń, nie od rozwiązania
Zanim padnie słowo „mikroserwis" albo „Kubernetes", warto odpowiedzieć na kilka pytań (swoją drogą GitOps z wykorzystaniem VPS opisywałem w tym artykule). Jaki problem biznesowy rozwiązujemy? Ile osób będzie ten system rozwijać? Jakie są realne wymagania co do dostępności i czasu odpowiedzi? Co się stanie, gdy usługa padnie na godzinę?
Odpowiedzi na te pytania eliminują większość „modnych" rozwiązań szybciej niż jakakolwiek dyskusja o technologii. System obsługiwany przez trzyosobowy zespół rzadko potrzebuje architektury zaprojektowanej dla organizacji zatrudniającej setki inżynierów.
Najdroższe decyzje architektoniczne to te, które podejmuje się na podstawie obecnych trendów — bo „tak się teraz robi". Jeszcze droższe może okazać się nie podejmowanie żadnych decyzji architektonicznych.
Granice ważniejsze niż warstwy
Kiedy znam już ograniczenia, zaczynam od granic. Nie od warstw technicznych (kontroler, serwis, repozytorium), tylko od granic domenowych: co jest osobnym obszarem odpowiedzialności, a co można trzymać razem.
W praktyce najczęściej sięgam po modular monolith: jeden proces wdrożeniowy, ale wyraźnie odseparowane moduły z jawnymi kontraktami. To daje większość korzyści z podziału na mikroserwisy, bez ich kosztu operacyjnego.
// Moduł komunikuje się ze światem tylko przez jawny kontrakt.
// Przykład CQRS.
public interface IBillingModule
{
// Wszystkie żądania mutujące stan (commands) przechodzą przez tę metodę
Task<IResult> ExecuteCommandAsync(
ICommand command,
CancellationToken ct);
// Wszystkie zapytania niemutujące (queries) przechodzą przez tę metodę
Task<IResult<TResponse>> ExecuteQueryAsync<TResult>(
IQuery<TResult> query,
CancellationToken cancellationToken = default);
}
Dopóki komunikacja między modułami przechodzi przez takie kontrakty, podział na osobne usługi pozostaje decyzją wdrożeniową — a nie architektoniczną, którą trzeba podjąć na starcie.
Stara mądrość głosi, że stosowanie architektury mikroserwisów ma uzasadnienie tylko wtedy, kiedy nie da się w danym przypadku zastosować modułowego monolitu.
Prawie każda aplikacja zaczyna swój żywot od monolitu, a skalowanie wymusza przejście na mikroserwisy.
Komunikacja między modułami
Sam podział kodu na moduły nie wystarcza. Jeżeli jeden moduł może odwołać się bezpośrednio do repozytorium, encji albo tabel należących do innego modułu, granica istnieje wyłącznie na diagramie.
Każdy moduł powinien być właścicielem swojego modelu, danych i reguł biznesowych. Pozostałe części systemu mogą komunikować się z nim wyłącznie przez jawnie zdefiniowane kontrakty.
W praktyce stosuję trzy podstawowe sposoby komunikacji między modułami:
- synchroniczne wywołanie publicznego API modułu,
- asynchroniczne zdarzenia integracyjne,
- orkiestrację procesu przez osobną warstwę aplikacyjną lub process manager.
Publiczne API modułu
Synchroniczne wywołanie sprawdza się wtedy, gdy moduł wywołujący potrzebuje odpowiedzi przed kontynuowaniem operacji. Przykładowo moduł odpowiedzialny za tworzenie zamówienia może zapytać moduł produktów o stany magazynowe lub zażądać zarezerwowania tych stanów.
// Publiczne API, dostępne do wykorzystania przez zewnętzne moduły
public interface IStockManagementModule
{
// Metoda publicznego API modułu magazynowego.
// Rezerwuje stany magazynowe produktów.
Task<Result> ReserveProducts(
ReserveProductsRequest request,
CancellationToken cancellationToken);
}
Publiczny kontrakt powinien opisywać przypadki użycia udostępniane przez moduł, a nie jego wewnętrzną strukturę. Nie powinien zwracać encji domenowych ani udostępniać repozytoriów.
Najprościej zdefiniować jawne, typowane operacje zamiast jednego uniwersalnego interfejsu przyjmującego dowolne komendy i zapytania. Ogólny dispatcher CQRS może być użyteczny wewnątrz modułu, ale wystawiony jako jedyny publiczny kontrakt łatwo ukrywa rzeczywiste zależności.
Dzięki typowanemu API już na poziomie kodu widać, z jakich możliwości modułu korzystają pozostałe części systemu.
Zdarzenia domenowe i integracyjne
Nie każde działanie wymaga synchronicznej odpowiedzi. Jeżeli moduł jedynie informuje, że coś się wydarzyło, lepszym rozwiązaniem jest zdarzenie integracyjne.
Przykładowo po utworzeniu maszyny moduł provisioningu może opublikować zdarzenie:
public sealed record VirtualMachineProvisioned(
Guid VirtualMachineId,
Guid CustomerId,
DateTimeOffset ProvisionedAt);
Zdarzenie może zostać obsłużone niezależnie przez moduły odpowiedzialne za:
- rozliczenia,
- powiadomienia,
- audyt,
- konfigurację monitoringu,
- aktualizację panelu klienta.
Moduł publikujący nie powinien wiedzieć, ilu odbiorców istnieje ani jakie działania wykonują. Informuje jedynie o fakcie, który już nastąpił.
Ważne jest przy tym rozróżnienie dwóch rodzajów zdarzeń:
- zdarzenia domenowe opisują zmiany wewnątrz pojedynczego modułu,
- zdarzenia integracyjne są stabilnym kontraktem przeznaczonym dla innych modułów lub usług.
Zdarzenie domenowe nie musi automatycznie stawać się zdarzeniem integracyjnym. Moduł może najpierw obsłużyć je wewnętrznie, a następnie opublikować uproszczony i stabilniejszy komunikat przeznaczony na zewnątrz.
W modularnym monolicie zdarzenia integracyjne mogą być początkowo obsługiwane wewnątrz tego samego procesu. Nie oznacza to jednak, że można pominąć problemy typowe dla komunikacji asynchronicznej.
Jeżeli publikacja zdarzenia musi być niezawodna, warto zastosować wzorce:
- Outbox — zapis zmiany stanu i komunikatu w tej samej transakcji,
- Inbox — ochrona konsumenta przed wielokrotnym przetworzeniem tego samego komunikatu,
- idempotent consumer — możliwość bezpiecznego ponowienia obsługi zdarzenia.
Dzięki temu lokalny event bus może zostać później zastąpiony brokerem, na przykład RabbitMQ obsługiwanym przez MassTransit, bez zmiany podstawowego modelu komunikacji.
Orkiestracja procesów
Niektóre operacje obejmują kilka modułów i składają się z wielu kroków. Nie powinny być wtedy implementowane jako łańcuch bezpośrednich wywołań pomiędzy modułami.
Przykładem może być proces utworzenia maszyny:
- Rezerwacja identyfikatora maszyny.
- Skopiowanie szablonu.
- Zastosowanie konfiguracji.
- Rezerwacja adresu IP.
- Utworzenie polityki kopii zapasowej.
- Uruchomienie maszyny.
- Aktywacja rozliczenia,
- Wysłanie powiadomienia.
Odpowiedzialność za taki proces powinna należeć do dedykowanego koordynatora, na przykład application service, process managera albo sagi.
Saga to popularny wzorzec architektoniczny służący do zarządzania transakcjami w systemach rozproszonych i architekturach mikroserwisów. Jego zadaniem jest orkiestrowanie i nadzór nad choreografią procesów rozproszonych.
public sealed class ProvisionVirtualMachineProcess
{
private readonly IVirtualMachinesModule _virtualMachines;
private readonly IIpAddressManagementModule _ipam;
private readonly IBillingModule _billing;
public ProvisionVirtualMachineProcess(
IVirtualMachinesModule virtualMachines,
IIpAddressManagementModule ipam,
IBillingModule billing)
{
_virtualMachines = virtualMachines;
_ipam = ipam;
_billing = billing;
}
public async Task<Result> ExecuteAsync(
ProvisionVirtualMachine command,
CancellationToken cancellationToken)
{
var machine = await _virtualMachines.CreateAsync(command, cancellationToken);
if (machine.IsFailure)
return machine.Error;
var address = await _ipam.ReserveAddressAsync(
new ReserveIpAddress(machine.Value.Id),
cancellationToken);
if (address.IsFailure)
{
await _virtualMachines.MarkProvisioningAsFailedAsync(
machine.Value.Id,
address.Error,
cancellationToken);
return address.Error;
}
await _billing.ActivateServiceAsync(
machine.Value.Id,
command.CustomerId,
cancellationToken);
return Result.Success();
}
}
Koordynator zna przebieg procesu, ale nie przejmuje logiki domenowej lub aplikacyjnej poszczególnych modułów. Każdy moduł nadal samodzielnie odpowiada za swoje reguły i stan.
Dla krótkich operacji wykonywanych w jednej transakcji wystarczy zwykły application service. Dla procesów długotrwałych, obejmujących operacje zewnętrzne, ponowienia i kompensacje lepiej sprawdzi się trwały process manager albo saga.
Stan takiego procesu powinien być zapisany. Dzięki temu awaria procesu, restart aplikacji albo chwilowa niedostępność zewnętrznego systemu nie powodują utraty informacji o aktualnym etapie operacji.
Kiedy stosować poszczególne mechanizmy
Połączenie całej tej wiedzy i odpowiednie stosowanie wzorców wymaga wprawy i doświadczenia. Dlatego poniżej krótkie podsumowanie kiedy stosować poszczególne wzorce.
Publiczne API modułu wybieram wtedy, gdy:
- odpowiedź jest potrzebna natychmiast,
- operacja wpływa na dalszy przebieg bieżącego przypadku użycia,
- odmowa wykonania operacji powinna przerwać proces.
Zdarzenie integracyjne wybieram wtedy, gdy:
- publikowany jest fakt, który już nastąpił,
- nadawca nie potrzebuje natychmiastowej odpowiedzi,
- odbiorców może być wielu,
- dopuszczalna jest spójność ostateczna (eventual consistency).
Orkiestrację wybieram wtedy, gdy:
- przypadek użycia obejmuje kilka modułów,
- proces ma wiele etapów,
- potrzebne są ponowienia, timeouty lub działania kompensacyjne,
- stan procesu musi przetrwać restart aplikacji.
Często najlepszym rozwiązaniem jest połączenie tych mechanizmów. Koordynator może synchronicznie wywołać operacje niezbędne do kontynuowania procesu, a po jego zakończeniu opublikować zdarzenie integracyjne dla działań ubocznych.
Granice, których nie należy przekraczać
Niezależnie od wybranego mechanizmu moduły nie powinny:
- odwoływać się bezpośrednio do tabel innych modułów,
- korzystać z ich repozytoriów,
- modyfikować ich agregatów lub encji,
- współdzielić modeli domenowych,
- opierać komunikacji na szczegółach implementacyjnych.
Współdzielone mogą być niewielkie elementy techniczne, takie jak Result, identyfikatory, abstrakcje zegara albo mechanizmy publikowania komunikatów. Współdzielony kod nie powinien jednak stawać się miejscem, do którego trafia logika biznesowa należąca do konkretnych modułów.
W przypadku integracji z systemem zewnętrznym lub starszą częścią aplikacji warto dodatkowo zastosować warstwę antykorupcyjną. Tłumaczy ona zewnętrzny model na pojęcia używane przez moduł i zapobiega przenikaniu obcych struktur do domeny.
Dobrze zaprojektowana komunikacja sprawia, że moduł można uruchomić w tym samym procesie, przenieść do osobnego procesu albo zastąpić zewnętrzną usługą bez przebudowy całego systemu. Takie podejście ułatwi w przyszłości migrację modułowego monolitu do architektury mikroserwisów.
Nie oznacza to, że wydzielenie mikroserwisu jest całkowicie bezkosztowe. Zmieniają się między innymi charakter transakcji, opóźnienia, obsługa błędów i wymagania dotyczące obserwowalności. Jawne kontrakty i brak współdzielonego stanu powodują jednak, że jest to kontrolowana zmiana infrastrukturalna, a nie konieczność rozcinania silnie sprzężonego monolitu.
Czy wiesz, że?
Odpowiednie nazewnictwo agregatów, encji, procesów i operacji jest częścią Ubiquitous Language — wspólnego języka używanego przez ekspertów domenowych i zespół techniczny.
To samo pojęcie może w różnych modułach pełnić zupełnie inną rolę, dlatego nie zawsze powinno mieć tę samą nazwę. Osoba korzystająca z systemu może być na przykład użytkownikiem w module tożsamości, klientem w module sprzedaży i autorem komentarza w module treści.
Choć technicznie może to być ta sama osoba, w każdej z tych domen obowiązują inne reguły, dane i zachowania.
Implementacja: kod, który da się utrzymać
Na poziomie implementacji trzymam się kilku zasad, które sprawdzają się niezależnie od projektu.
- Logika domenowa nie zależy od frameworka ani od bazy danych.
- Efekty uboczne (I/O, sieć, czas) są odsunięte na brzeg systemu.
- Testy pokrywają zachowania, nie szczegóły implementacji.
To nie jest dogmatyczne Clean Architecture dla samej czystości. Chodzi o to, żeby zmiana w jednym miejscu nie wymagała ostrożnego przechodzenia przez cały system.
Pamiętajmy, że Clean Architecture to nie struktura folderów, CQRS, wzorzec mediatora czy ładnie ponazywane klasy. Czysta architekrura to realne zależności między warstwami systemu.
Produkcja zaczyna się przed pierwszym wdrożeniem
Najczęstszy błąd to traktowanie wdrożenia jako ostatniego kroku. W praktyce o produkcji trzeba myśleć od początku: jak aplikacja się konfiguruje, jak loguje, jak sygnalizuje swój stan. Na podstawie własnego doświadczenia mogę powiedzieć, że przygotowanie do wdrożenia produkcyjnego zaczyna się w momencie, gdy podejmowane są pierwsze decyzje architektoniczne, a sam proces wdrożenia jest złożony i wielowarstwowy.
# Zdrowie aplikacji to część kontraktu z infrastrukturą, nie dodatek.
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
interval: 10s
timeout: 3s
retries: 3
Zanim system trafi na produkcję, powinien posiadać bezwzględne minimum:
- końcówkę zdrowia, czyli tzw. "health check",
- ustrukturyzowane logi, zawierające co najmniej logowanie krytycznych procesów, błędów, wyjątków,
- podstawowe metryki oraz jasną procedurę wdrożenia i wycofania zmiany.
Te dwa ostatnie często nazywane są "obserwowalnością". Całość to minimum, które zamienia "apka wystartowała" w coś więcej: „ta aplikacja działa i można nią zarządzać".
Stabilizacja i przekazanie
Po wdrożeniu przychodzi etap, który łatwo pominąć: obserwacja systemu pod realnym ruchem, dostrojenie alertów i sprawdzenie, że backupy naprawdę da się odtworzyć. Backup, którego nigdy nie przywrócono, jest tylko nadzieją zapisaną na dysku.
Na końcu zostaje dokumentacja i przekazanie wiedzy. System, który rozumie tylko jego autor, jest ryzykiem operacyjnym — niezależnie od tego, jak dobrze został napisany.
Podsumowanie
Droga od pomysłu do produkcji nie jest liniowa, ale ma sensowną kolejność: najpierw ograniczenia, potem granice, potem implementacja, a przez cały czas — myślenie o tym, jak to będzie działać i jak będzie zarządzane. To podejście nie gwarantuje, że nic się nie zepsuje. Gwarantuje, że gdy się zepsuje, będzie wiadomo co, dlaczego i jak przywrócić do normy.