Monolit modularny vs mikroserwisy - dlaczego nie potrzebujesz Kubernetesa

CTO pewnej firmy logistycznej opowiedział mi historię, którą słyszę co kwartał. Consultingowa firma doradcza zaproponowała im „nowoczesną architekturę mikroserwisową". 14 serwisów, Kubernetes, API Gateway, service mesh, distributed tracing. Budżet: 1,8 mln PLN, czas: 12 miesięcy.

Po 18 miesiącach mieli 9 z 14 serwisów, zerowy deployment na produkcję i zespół 11 osób, z czego 4 zajmowały się wyłącznie infrastrukturą. Budżet przekroczony dwukrotnie.

Zbudowaliśmy im ten sam system jako monolit modularny w Phoenix. 4 osoby, 5 miesięcy, rachunek za infrastrukturę: 2 400 PLN/miesiąc zamiast planowanych 18 000 PLN.

Mit mikroserwisów

Mikroserwisy rozwiązują prawdziwy problem - ale problem, którego 99% firm nie ma.

Netflix ma mikroserwisy, bo zatrudnia 2 000 inżynierów, którzy pracują nad różnymi częściami systemu jednocześnie i deployują 100 razy dziennie. Amazon ma mikroserwisy, bo obsługuje miliardy requestów i potrzebuje niezależnego skalowania poszczególnych komponentów.

Twoja firma ma 5-20 developerów i deployuje raz w tygodniu. Czy naprawdę potrzebujesz tej samej architektury co Netflix?

Co mikroserwisy naprawdę dają

KorzyśćKiedy ma sensKiedy nie ma sensu
Niezależne deployowanieZespół 50+ osób, wiele teamówZespół < 20 osób
Niezależne skalowanieRadykalnie różne obciążenie per serwisPodobne obciążenie wszędzie
Różne technologie per serwisSpecyficzne wymagania (ML w Pythonie, realtime w Elixirze)Cały system w jednym stacku
Izolacja awariiBrak izolacji w monolicieBEAM daje izolację procesów natywnie

Co mikroserwisy naprawdę kosztują

To jest lista, o której nikt nie mówi na konferencjach:

Infrastruktura mikroserwisów:
├── Kubernetes cluster              → 3 000 - 8 000 PLN/mies.
├── Container registry              → 200 PLN/mies.
├── API Gateway                     → 400 - 1 500 PLN/mies.
├── Service mesh (Istio/Linkerd)    → Czas konfiguracji
├── Distributed tracing (Jaeger)    → 500 - 2 000 PLN/mies.
├── Centralized logging (ELK)       → 1 000 - 3 000 PLN/mies.
├── Message broker (Kafka/RabbitMQ) → 800 - 2 500 PLN/mies.
├── Service discovery               → Wbudowane w K8s
├── Secret management (Vault)       → 500 - 1 500 PLN/mies.
├── CI/CD per serwis (×14)          → 2 000 PLN/mies.
└── Monitoring per serwis           → 1 000 - 3 000 PLN/mies.
────────────────────────────────────────────────
Razem infrastruktura:                9 400 - 25 000 PLN/mies.

Infrastruktura monolitu modularnego:
├── 1-2 serwery aplikacyjne         → 1 200 - 2 400 PLN/mies.
├── 1 serwer PostgreSQL             → 1 200 PLN/mies.
├── CI/CD (1 pipeline)              → 200 PLN/mies.
├── Monitoring                      → 500 PLN/mies.
└── Backup                          → 300 PLN/mies.
────────────────────────────────────────────────
Razem infrastruktura:                3 400 - 4 600 PLN/mies.

Różnica: 6 000 - 20 000 PLN miesięcznie - tylko na infrastrukturze. Do tego dochodzi koszt zespołu.

Czym jest monolit modularny

Monolit modularny to jedna aplikacja podzielona na niezależne moduły (konteksty biznesowe), które komunikują się przez zdefiniowane interfejsy. Ma zalety mikroserwisów (separacja odpowiedzialności, czyste granice) bez ich kosztów (sieć, serializacja, distributed transactions).

Porównanie architektur: mikroserwisy (3 serwisy, 3 bazy, HTTP/JSON) vs monolit modularny Phoenix (3 konteksty, 1 PostgreSQL)

Mikroserwisy: 3 bazy, 3 deploye, 3 CI/CD, API Gateway, service discovery... Monolit: 1 baza, 1 deploy, 1 CI/CD, zero API Gateway.

Jak wygląda monolit modularny w Phoenix

Konteksty jako moduły biznesowe

# lib/my_app/orders.ex - kontekst zamówień
defmodule MyApp.Orders do
  @moduledoc "Kontekst zamówień - publiczny interfejs modułu"

  alias MyApp.Orders.{Order, OrderItem}
  alias MyApp.Repo

  def create_order(attrs) do
    %Order{}
    |> Order.create_changeset(attrs)
    |> Repo.insert()
    |> notify_order_created()
  end

  def confirm_order(order_id, confirmed_by) do
    order = get_order!(order_id)

    Ecto.Multi.new()
    |> Ecto.Multi.update(:order, Order.confirm_changeset(order))
    |> Ecto.Multi.run(:stock, fn _, _ ->
      MyApp.Inventory.reserve_stock(order)
    end)
    |> Repo.transaction()
    |> notify_order_confirmed()
  end

  # Prywatne funkcje - inny moduł ich nie widzi
  defp notify_order_created({:ok, order} = result) do
    Phoenix.PubSub.broadcast(MyApp.PubSub, "orders", {:order_created, order})
    result
  end
  defp notify_order_created(error), do: error
end
# lib/my_app/inventory.ex - kontekst magazynu
defmodule MyApp.Inventory do
  @moduledoc "Kontekst magazynowy - publiczny interfejs"

  def reserve_stock(order) do
    # Implementacja ukryta za interfejsem
    # Orders nie wie JAK magazyn rezerwuje stock
    # Wie tylko, że może wywołać tę funkcję
  end

  def check_availability(product_id, quantity) do
    # ...
  end
end
# lib/my_app/payments.ex - kontekst płatności
defmodule MyApp.Payments do
  @moduledoc "Kontekst płatności"

  def process_payment(order) do
    # ...
  end

  def refund(payment_id, reason) do
    # ...
  end
end

Każdy kontekst:

  • Ma publiczny interfejs - zestaw funkcji, które inne moduły mogą wywoływać
  • Ukrywa implementację - schematy Ecto, prywatne funkcje, wewnętrzne procesy
  • Posiada własne tabele - orders.*, inventory.*, payments.*
  • Komunikuje się przez PubSub - luźne wiązanie między modułami

Granice modułów = granice biznesowe

lib/my_app/
├── orders/                    # Kontekst zamówień
│   ├── order.ex               # Schema + changesety
│   ├── order_item.ex
│   └── order_notifier.ex
├── orders.ex                  # Publiczny interfejs (facade)

├── inventory/                 # Kontekst magazynu
│   ├── product.ex
│   ├── stock_level.ex
│   └── reservation.ex
├── inventory.ex               # Publiczny interfejs

├── payments/                  # Kontekst płatności
│   ├── payment.ex
│   ├── refund.ex
│   └── payment_gateway.ex
├── payments.ex                # Publiczny interfejs

├── shipping/                  # Kontekst wysyłki
│   ├── shipment.ex
│   ├── tracking.ex
│   └── carrier_api.ex
├── shipping.ex                # Publiczny interfejs

└── accounts/                  # Kontekst użytkowników
    ├── user.ex
    ├── user_token.ex
    └── user_notifier.ex

Reguła: moduł Orders może wywołać Inventory.reserve_stock/1, ale nie może importować MyApp.Inventory.StockLevel ani pisać zapytań do tabel inventory_* bezpośrednio. Granica kontekstu to granica interfejsu.

Komunikacja między modułami - PubSub

# Zamówienie potwierdzone → magazyn rezerwuje, płatności wystawiają fakturę,
# shipping przygotowuje etykietę, dashboard się aktualizuje

# W module Orders (producent zdarzenia):
defp notify_order_confirmed({:ok, %{order: order}} = result) do
  Phoenix.PubSub.broadcast(MyApp.PubSub, "orders", {:order_confirmed, order})
  result
end

# W module Shipping (konsument zdarzenia):
defmodule MyApp.Shipping.OrderSubscriber do
  use GenServer

  def start_link(_) do
    GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
  end

  def init(:ok) do
    Phoenix.PubSub.subscribe(MyApp.PubSub, "orders")
    {:ok, %{}}
  end

  def handle_info({:order_confirmed, order}, state) do
    MyApp.Shipping.prepare_label(order)
    {:noreply, state}
  end

  # Ignoruj inne zdarzenia
  def handle_info(_, state), do: {:noreply, state}
end

To jest ten sam wzorzec co event-driven microservices, ale bez Kafki, bez serializacji JSON, bez opóźnień sieciowych. PubSub działa w pamięci - latencja w mikrosekundach.

Porównanie: co dostajesz w każdej architekturze

AspektMikroserwisyMonolit modularny (Phoenix)
Separacja modułówFizyczna (sieć)Logiczna (konteksty Elixir)
KomunikacjaHTTP/gRPC + JSONWywołanie funkcji / PubSub
Latencja komunikacji1-10 ms (sieć)< 0.01 ms (pamięć)
Transakcje cross-moduleSaga pattern (złożone)Ecto.Multi (proste)
Niezależny deployTakNie (ale deploy trwa sekundy)
Niezależne skalowanieTakNie (ale 1 serwer wystarczy)
Izolacja awariiOsobne procesy OSProcesy BEAM (supervisor trees)
Baza danychOsobna per serwisWspółdzielona (osobne schematy)
MonitoringDistributed tracingTelemetry + Logger
Złożoność DevOpsBardzo wysokaNiska

Gdzie mikroserwisy bolą najbardziej

Problem 1: Distributed transactions

Klient składa zamówienie. Musisz: utworzyć zamówienie, zarezerwować stock, obciążyć kartę. W monolicie:

# Monolit - jedna transakcja, atomowa
Ecto.Multi.new()
|> Ecto.Multi.insert(:order, Order.changeset(attrs))
|> Ecto.Multi.run(:stock, fn _, %{order: order} ->
  Inventory.reserve(order.items)
end)
|> Ecto.Multi.run(:payment, fn _, %{order: order} ->
  Payments.charge(order)
end)
|> Repo.transaction()

# Albo WSZYSTKO się uda, albo NIC się nie zmieni.
# Zero niespójności. Zero "zapłacił ale stock nie zarezerwowany".

W mikroserwisach:

Orders Service → "utwórz zamówienie"     ✓
Orders → Inventory: "zarezerwuj stock"    ✓
Orders → Payments: "pobierz płatność"     ✗ (karta odrzucona)

# Teraz musisz:
# 1. Cofnąć rezerwację stocku (compensating transaction)
# 2. Oznaczyć zamówienie jako anulowane
# 3. Obsłużyć sytuację, gdy Inventory nie odpowiada na cofnięcie
# 4. Obsłużyć sytuację, gdy sieć padnie w trakcie cofania
# 5. Zaimplementować Saga pattern z choreografią lub orkiestracją
# 6. Dodać dead letter queue na nieprzetworzone kompensacje
# 7. Monitorować niespójności i naprawiać ręcznie

# To jest TYSIĄCE linii kodu infrastrukturalnego
# zamiast 10 linii Ecto.Multi

Problem 2: Debugging

Użytkownik zgłasza: „zamówienie #4521 nie zostało wysłane".

Monolit: Jeden log, jedno miejsce, grep 4521 app.log - masz pełną historię.

Mikroserwisy: Request przeszedł przez API Gateway → Orders → Inventory → Payments → Shipping → Notifications. Logi w 6 różnych miejscach. Potrzebujesz distributed tracing (Jaeger/Zipkin), correlation ID propagowanego przez wszystkie serwisy, centralizowanego logowania (ELK stack). I modlisz się, żeby nikt nie zapomniał przekazać correlation ID.

Problem 3: Refactoring granic

Po 6 miesiącach okazuje się, że „Payments" powinno być częścią „Orders", a nie osobnym serwisem. W monolicie - przesuwasz pliki, zmieniasz import. W mikroserwisach - migrujesz bazę danych, przepisujesz API, aktualizujesz wszystkie serwisy klienckie, zmieniasz CI/CD, routing, monitoring. Tydzień pracy zamiast godziny.

BEAM daje izolację bez mikroserwisów

Główny argument za mikroserwisami to izolacja awarii. Ale BEAM daje ją natywnie:

# Supervisor tree - izolacja awarii w monolicie
children = [
  # Każdy kontekst ma własny supervisor
  {MyApp.Orders.Supervisor, []},
  {MyApp.Inventory.Supervisor, []},
  {MyApp.Payments.Supervisor, []},
  {MyApp.Shipping.Supervisor, []}
]

Supervisor.start_link(children, strategy: :one_for_one)

# Jeśli Shipping.Supervisor padnie:
# → Orders, Inventory, Payments działają dalej
# → Supervisor restartuje Shipping w mikrosekundach
# → Zero utraty danych, zero downtime'u
#
# To jest TA SAMA izolacja co w mikroserwisach
# Bez sieci, bez Kubernetesa, bez distributed tracing
Aspekt izolacjiMikroserwisyBEAM (monolit)
Crash jednego modułuNie wpływa na inneNie wpływa na inne
Memory leakIzolowany do konteneraIzolowany do procesu BEAM
CPU spikeIzolowany (resource limits)Scheduler preemptive (BEAM)
Restart po awariiKubernetes restartuje pod (sekundy)Supervisor restartuje proces (μs)
Deployment nowej wersjiRolling update (minuty)Hot code reload (sekundy)

Kiedy monolit, kiedy mikroserwisy

Zacznij od monolitu, gdy:

  • Zespół < 20 programistów
  • Firma ma 1-3 produkty/systemy
  • Obciążenie jest przewidywalne
  • Budżet na infrastrukturę < 15 000 PLN/miesiąc
  • Czas dostarczenia ma znaczenie (time to market)
  • Nie masz dedykowanego zespołu DevOps

Rozważ mikroserwisy, gdy:

  • Zespół > 50 programistów, wiele niezależnych teamów
  • Różne moduły wymagają radykalnie różnych technologii
  • Jedno z usług wymaga 100× większego skalowania niż reszta
  • Masz dedykowany zespół platformowy (DevOps/SRE)
  • Deployujesz 10+ razy dziennie i potrzebujesz niezależnych cykli

Ścieżka ewolucji

Najlepsze podejście: zacznij od monolitu modularnego, wydziel mikroserwis gdy pojawi się realna potrzeba:

Ścieżka ewolucji: miesiąc 1-12 monolit, miesiąc 12+ wydzielenie ML Service, miesiąc 24+ wydzielenie Shipping Service

Wydzielasz mikroserwis gdy masz dowód, że jest potrzebny - nie „na zapas".

Porównanie kosztów (3 lata)

Scenariusz: system ERP/CRM z 5 modułami biznesowymi, 50-100 użytkowników.

SkładnikMikroserwisy (K8s)Monolit (Phoenix)
Infrastruktura/mies.15 000 PLN4 000 PLN
DevOps (osoba/pół etatu)10 000 PLN/mies.0 (developer wystarczy)
Czas budowy MVP8-12 mies.4-6 mies.
Zespół budowy6-8 osób3-4 osoby
Koszt budowy800 000 - 1 200 000 PLN300 000 - 500 000 PLN
Utrzymanie/rok250 000 PLN100 000 PLN
TCO 3 lata2 050 000 - 2 650 000 PLN750 000 - 1 000 000 PLN

Różnica: 1,3 - 1,6 mln PLN w 3 lata. Za te pieniądze możesz zatrudnić 5 programistów na rok albo zbudować drugi system.

Podsumowanie

Mikroserwisy to potężne narzędzie - dla firm, które ich potrzebują. Dla pozostałych 95% to overengineering, który kosztuje 2-3× więcej, trwa 2× dłużej i wymaga 2× większego zespołu.

Monolit modularny w Phoenix daje:

  • Separację modułów - konteksty z czystymi interfejsami
  • Izolację awarii - supervisor trees, nie Kubernetes
  • Komunikację event-driven - PubSub, nie Kafka
  • Transakcje atomowe - Ecto.Multi, nie Saga pattern
  • Prosty deployment - jeden build, jedna komenda
  • Ścieżkę ewolucji - wydziel mikroserwis, gdy pojawi się realna potrzeba

Nie potrzebujesz architektury Netflixa. Potrzebujesz systemu, który działa, jest łatwy w utrzymaniu i kosztuje rozsądnie.


Zastanawiasz się, jaka architektura będzie optymalna dla Twojego systemu? Porozmawiajmy - pomożemy wybrać rozwiązanie dopasowane do Twojej skali.