GenServer - jak Elixir zamienia procesy biznesowe w żywe obiekty w pamięci
W tradycyjnym systemie webowym proces biznesowy nie istnieje. Jest tylko baza danych i requesty HTTP. Klient klika „dodaj do koszyka" - system zapisuje wiersz w tabeli cart_items. Klika „złóż zamówienie" - system czyta z bazy, przetwarza, zapisuje z powrotem. Między kliknięciami nic się nie dzieje. System nie wie, że koszyk istnieje, dopóki ktoś o niego nie zapyta.
W Elixirze koszyk naprawdę istnieje. Jest procesem - kawałkiem kodu, który żyje w pamięci, pamięta swój stan, reaguje na zdarzenia i sam podejmuje decyzje (np. „minęło 30 minut bez aktywności - zwolnij zarezerwowane produkty"). To nie jest cache. To nie jest sesja. To GenServer.
Czym jest GenServer
GenServer (Generic Server) to abstrakcja nad procesem BEAM, która daje Ci:
- Stan - proces przechowuje dane w pamięci, między wywołaniami
- Interfejs - inne procesy komunikują się z nim przez wiadomości
- Gwarancje - wiadomości są przetwarzane po kolei, jedna po drugiej
- Nadzór - supervisor restartuje proces, jeśli coś pójdzie nie tak
defmodule MyApp.Counter do
use GenServer
# --- Interfejs publiczny (API) ---
def start_link(initial_value) do
GenServer.start_link(__MODULE__, initial_value)
end
def increment(pid) do
GenServer.call(pid, :increment)
end
def get_value(pid) do
GenServer.call(pid, :get_value)
end
# --- Implementacja (callbacks) ---
@impl true
def init(initial_value) do
{:ok, initial_value}
end
@impl true
def handle_call(:increment, _from, state) do
new_state = state + 1
{:reply, new_state, new_state}
end
@impl true
def handle_call(:get_value, _from, state) do
{:reply, state, state}
end
end
# Użycie:
{:ok, pid} = MyApp.Counter.start_link(0)
MyApp.Counter.increment(pid) #=> 1
MyApp.Counter.increment(pid) #=> 2
MyApp.Counter.get_value(pid) #=> 2To wygląda na zwykły obiekt z metodami. Ale jest fundamentalna różnica: GenServer to osobny proces. Ma własną pamięć, własny garbage collector, własny stos wywołań. Crash tego procesu nie wpływa na żaden inny.
Dlaczego to zmienia architekturę
W klasycznym systemie webowym:
Request → Controller → Serwis → Baza → Serwis → Response
Request → Controller → Serwis → Baza → Serwis → Response
Request → Controller → Serwis → Baza → Serwis → Response
Każdy request: odczyt z bazy → obliczenia → zapis do bazy
Baza danych = jedyne źródło stanu
Baza danych = wąskie gardłoW systemie z GenServerami:
┌──────────────┐
Request ──────────► │ GenServer │ ──► Response
│ (stan w RAM) │
└──────────────┘
│
(zapis do bazy
asynchronicznie,
co N sekund
lub przy zamknięciu)Stan jest w pamięci, nie w bazie. Odpowiedź jest natychmiastowa - nie czekasz na PostgreSQL. Baza danych służy do trwałości, nie do obsługi każdego kliknięcia.
Przykład 1: Koszyk zakupowy
Typowy koszyk w e-commerce - w tradycyjnym podejściu każda operacja to zapytanie do bazy:
Dodaj produkt: INSERT INTO cart_items (...) → 3-8 ms
Zmień ilość: UPDATE cart_items SET ... → 3-8 ms
Pobierz koszyk: SELECT ... JOIN products JOIN prices → 5-15 ms
Oblicz sumę: SELECT SUM(...) → 3-8 ms
Sprawdź promocje: SELECT ... FROM promotions → 5-10 ms
────────────────────────────────────────────────
Jedno kliknięcie „dodaj do koszyka": 20-50 ms, 3-5 zapytań SQLGenServer - koszyk jako żywy proces:
defmodule MyApp.Cart do
use GenServer
defstruct [:user_id, :items, :total, :discount, :updated_at]
# --- API ---
def start_link(user_id) do
GenServer.start_link(__MODULE__, user_id, name: via(user_id))
end
def add_item(user_id, product_id, quantity \\ 1) do
GenServer.call(via(user_id), {:add_item, product_id, quantity})
end
def remove_item(user_id, product_id) do
GenServer.call(via(user_id), {:remove_item, product_id})
end
def get_summary(user_id) do
GenServer.call(via(user_id), :get_summary)
end
def checkout(user_id) do
GenServer.call(via(user_id), :checkout)
end
defp via(user_id), do: {:via, Registry, {MyApp.CartRegistry, user_id}}
# --- Implementacja ---
@impl true
def init(user_id) do
# Wczytaj koszyk z bazy (jeśli istnieje) przy starcie
cart = load_from_db(user_id)
# Ustaw timer - jeśli 30 min bez aktywności, zwolnij zasoby
timer = Process.send_after(self(), :timeout, :timer.minutes(30))
{:ok, %{cart: cart, timer: timer}}
end
@impl true
def handle_call({:add_item, product_id, quantity}, _from, state) do
product = Products.get_with_price!(product_id)
cart =
state.cart
|> add_or_update_item(product, quantity)
|> apply_promotions()
|> recalculate_total()
state = %{state | cart: cart} |> reset_timer()
schedule_persist()
{:reply, {:ok, cart_summary(cart)}, state}
end
@impl true
def handle_call(:checkout, _from, state) do
case Orders.create_from_cart(state.cart) do
{:ok, order} ->
{:stop, :normal, {:ok, order}, state}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
@impl true
def handle_info(:persist, state) do
persist_to_db(state.cart)
{:noreply, state}
end
@impl true
def handle_info(:timeout, state) do
# 30 minut bez aktywności - zapisz i zakończ proces
persist_to_db(state.cart)
release_reserved_stock(state.cart)
{:stop, :normal, state}
end
defp schedule_persist do
# Zapisuj do bazy co 5 sekund, nie przy każdej operacji
Process.send_after(self(), :persist, :timer.seconds(5))
end
defp reset_timer(state) do
Process.cancel_timer(state.timer)
timer = Process.send_after(self(), :timeout, :timer.minutes(30))
%{state | timer: timer}
end
endRezultat:
| Metryka | Tradycyjny (baza) | GenServer (pamięć) |
|---|---|---|
| „Dodaj do koszyka" | 20-50 ms | < 1 ms |
| Zapytania SQL per kliknięcie | 3-5 | 0 |
| Obciążenie bazy (100 koszyków) | 300-500 zapytań/sek | ~20 zapytań/sek |
| Auto-cleanup nieaktywnych | Cron job co godzinę | Timer per koszyk (dokładny) |
| Rezerwacja stocku | Ręczna (osobny system) | Wbudowana w proces |
100 aktywnych koszyków to 100 procesów BEAM - ~260 KB pamięci łącznie. Baza danych oddycha, użytkownik ma natychmiastowe odpowiedzi.
Przykład 2: Maszyna stanowa zamówienia
Zamówienie przechodzi przez stany: draft → confirmed → paid → shipped → completed. W tradycyjnym systemie to kolumna status w bazie i nadzieja, że nikt nie ustawi jej ręcznie na wartość, której nie powinien.
W GenServerze zamówienie wie, w jakim jest stanie, i samo pilnuje reguł:
defmodule MyApp.OrderProcess do
use GenServer
@transitions %{
draft: %{confirm: :confirmed, cancel: :cancelled},
confirmed: %{pay: :paid, cancel: :cancelled},
paid: %{ship: :shipped, refund: :refunded},
shipped: %{deliver: :completed, return: :returned},
completed: %{},
cancelled: %{},
refunded: %{},
returned: %{}
}
# --- API ---
def start_link(order_id) do
GenServer.start_link(__MODULE__, order_id, name: via(order_id))
end
def confirm(order_id, confirmed_by) do
GenServer.call(via(order_id), {:transition, :confirm, %{confirmed_by: confirmed_by}})
end
def pay(order_id, payment_ref) do
GenServer.call(via(order_id), {:transition, :pay, %{payment_ref: payment_ref}})
end
def ship(order_id, tracking_number) do
GenServer.call(via(order_id), {:transition, :ship, %{tracking_number: tracking_number}})
end
def get_status(order_id) do
GenServer.call(via(order_id), :get_status)
end
def get_history(order_id) do
GenServer.call(via(order_id), :get_history)
end
defp via(order_id), do: {:via, Registry, {MyApp.OrderRegistry, order_id}}
# --- Implementacja ---
@impl true
def init(order_id) do
order = Orders.get_order!(order_id)
history = Orders.get_history(order_id)
{:ok, %{order: order, status: order.status, history: history}}
end
@impl true
def handle_call({:transition, action, metadata}, _from, state) do
current = state.status
transitions = Map.get(@transitions, current, %{})
case Map.get(transitions, action) do
nil ->
{:reply, {:error, "nie można wykonać #{action} w stanie #{current}"}, state}
next_status ->
case execute_transition(state, action, next_status, metadata) do
{:ok, new_state} ->
{:reply, {:ok, next_status}, new_state}
{:error, reason} ->
{:reply, {:error, reason}, state}
end
end
end
@impl true
def handle_call(:get_status, _from, state) do
{:reply, state.status, state}
end
@impl true
def handle_call(:get_history, _from, state) do
{:reply, state.history, state}
end
defp execute_transition(state, action, next_status, metadata) do
# Walidacja specyficzna dla przejścia
with :ok <- validate_transition(state, action, metadata),
{:ok, order} <- persist_transition(state.order, next_status, metadata),
:ok <- emit_side_effects(action, order, metadata) do
event = %{
from: state.status,
to: next_status,
action: action,
metadata: metadata,
at: DateTime.utc_now()
}
{:ok, %{state |
order: order,
status: next_status,
history: [event | state.history]
}}
end
end
defp validate_transition(state, :pay, %{payment_ref: ref}) do
cond do
is_nil(ref) -> {:error, "brak referencji płatności"}
state.order.total_price |> Decimal.eq?(0) -> {:error, "zamówienie na 0 PLN"}
true -> :ok
end
end
defp validate_transition(state, :ship, %{tracking_number: tn}) do
if is_nil(tn) or String.trim(tn) == "" do
{:error, "brak numeru przesyłki"}
else
:ok
end
end
defp validate_transition(_state, _action, _metadata), do: :ok
defp emit_side_effects(:confirm, order, _meta) do
Phoenix.PubSub.broadcast(MyApp.PubSub, "orders", {:order_confirmed, order})
Notifications.send_confirmation(order)
:ok
end
defp emit_side_effects(:ship, order, %{tracking_number: tn}) do
Phoenix.PubSub.broadcast(MyApp.PubSub, "orders", {:order_shipped, order})
Notifications.send_shipping_info(order, tn)
:ok
end
defp emit_side_effects(_action, _order, _meta), do: :ok
endCo daje ten model:
- Niemożliwe przejścia są niemożliwe - nie da się wysłać nieopłaconego zamówienia
- Historia jest w pamięci - pełna ścieżka audytowa bez dodatkowych zapytań
- Side effects są automatyczne - potwierdzenie zamówienia = email + PubSub, zawsze
- Walidacja per przejście - wysyłka wymaga tracking number, płatność wymaga referencji
- Dashboard w czasie rzeczywistym - PubSub rozgłasza każdą zmianę do LiveView
Przykład 3: Rate limiter
Klasyczny problem: API partnera pozwala na 100 requestów na minutę. Jak pilnować limitu bez Redisa?
defmodule MyApp.RateLimiter do
use GenServer
def start_link(opts) do
name = Keyword.fetch!(opts, :name)
limit = Keyword.get(opts, :limit, 100)
window_ms = Keyword.get(opts, :window_ms, 60_000)
GenServer.start_link(__MODULE__, %{limit: limit, window_ms: window_ms}, name: name)
end
@doc "Sprawdź, czy można wykonać request. Zwraca :ok lub {:error, :rate_limited}"
def check(name) do
GenServer.call(name, :check)
end
@doc "Wykonaj funkcję z rate limitingiem"
def execute(name, fun) do
case check(name) do
:ok -> fun.()
{:error, :rate_limited} = err -> err
end
end
# --- Implementacja ---
@impl true
def init(config) do
{:ok, %{
config: config,
requests: :queue.new(),
count: 0
}}
end
@impl true
def handle_call(:check, _from, state) do
now = System.monotonic_time(:millisecond)
state = purge_old_requests(state, now)
if state.count < state.config.limit do
requests = :queue.in(now, state.requests)
{:reply, :ok, %{state | requests: requests, count: state.count + 1}}
else
{:reply, {:error, :rate_limited}, state}
end
end
defp purge_old_requests(state, now) do
cutoff = now - state.config.window_ms
{requests, count} = drop_before(state.requests, state.count, cutoff)
%{state | requests: requests, count: count}
end
defp drop_before(queue, count, cutoff) do
case :queue.peek(queue) do
{:value, timestamp} when timestamp < cutoff ->
{_, queue} = :queue.out(queue)
drop_before(queue, count - 1, cutoff)
_ ->
{queue, count}
end
end
end
# Użycie:
# W Application supervisor:
children = [
{MyApp.RateLimiter, name: :api_partner_x, limit: 100, window_ms: 60_000},
{MyApp.RateLimiter, name: :api_partner_y, limit: 50, window_ms: 60_000}
]
# W kodzie:
MyApp.RateLimiter.execute(:api_partner_x, fn ->
PartnerAPI.send_order(order_data)
end)
# => {:ok, response} lub {:error, :rate_limited}Zero Redisa. Zero zewnętrznych bibliotek. 50 linii kodu, działa natychmiast, precyzyjne sliding window.
Przykład 4: Cache z automatycznym odświeżaniem
Cennik produktów zmienia się raz na godzinę. Nie chcesz odpytywać bazy przy każdym requeście, ale potrzebujesz aktualnych danych:
defmodule MyApp.PriceCache do
use GenServer
@refresh_interval :timer.minutes(15)
def start_link(_opts) do
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
end
def get_price(product_id) do
GenServer.call(__MODULE__, {:get_price, product_id})
end
def get_all_prices do
GenServer.call(__MODULE__, :get_all)
end
# --- Implementacja ---
@impl true
def init(:ok) do
prices = load_all_prices()
schedule_refresh()
{:ok, %{prices: prices, loaded_at: DateTime.utc_now()}}
end
@impl true
def handle_call({:get_price, product_id}, _from, state) do
price = Map.get(state.prices, product_id)
{:reply, price, state}
end
@impl true
def handle_call(:get_all, _from, state) do
{:reply, state.prices, state}
end
@impl true
def handle_info(:refresh, _state) do
prices = load_all_prices()
schedule_refresh()
{:noreply, %{prices: prices, loaded_at: DateTime.utc_now()}}
end
defp load_all_prices do
Products.list_active_prices()
|> Map.new(fn p -> {p.id, p.price} end)
end
defp schedule_refresh do
Process.send_after(self(), :refresh, @refresh_interval)
end
end
# 10 000 requestów/sekundę pyta o ceny
# 0 zapytań do bazy - wszystko z pamięci
# Dane odświeżane co 15 minut automatycznie
# Jeśli proces padnie - supervisor restartuje, init/1 ładuje z bazyPrzykład 5: Sesja użytkownika z timeout
W tradycyjnym systemie sesja to rekord w bazie lub wpis w Redis. W Elixirze sesja to żywy proces, który sam się kończy po czasie nieaktywności:
defmodule MyApp.UserSession do
use GenServer
@session_timeout :timer.minutes(30)
def start_link(user_id) do
GenServer.start_link(__MODULE__, user_id, name: via(user_id))
end
def touch(user_id) do
GenServer.cast(via(user_id), :touch)
end
def get_activity(user_id) do
GenServer.call(via(user_id), :get_activity)
end
def active_sessions_count do
Registry.count(MyApp.SessionRegistry)
end
defp via(user_id), do: {:via, Registry, {MyApp.SessionRegistry, user_id}}
@impl true
def init(user_id) do
state = %{
user_id: user_id,
started_at: DateTime.utc_now(),
last_activity: DateTime.utc_now(),
page_views: 0
}
timer = Process.send_after(self(), :session_timeout, @session_timeout)
Phoenix.PubSub.broadcast(MyApp.PubSub, "sessions", {:session_started, user_id})
{:ok, Map.put(state, :timer, timer)}
end
@impl true
def handle_cast(:touch, state) do
Process.cancel_timer(state.timer)
timer = Process.send_after(self(), :session_timeout, @session_timeout)
{:noreply, %{state |
last_activity: DateTime.utc_now(),
page_views: state.page_views + 1,
timer: timer
}}
end
@impl true
def handle_call(:get_activity, _from, state) do
{:reply, Map.delete(state, :timer), state}
end
@impl true
def handle_info(:session_timeout, state) do
Phoenix.PubSub.broadcast(MyApp.PubSub, "sessions", {:session_ended, state.user_id})
AuditLog.log_session(state)
{:stop, :normal, state}
end
end
# Ile aktywnych sesji? Jedno wywołanie, zero bazy:
MyApp.UserSession.active_sessions_count()
#=> 847
# Dashboard admina widzi w czasie rzeczywistym:
# - Kto jest zalogowany
# - Ile wyświetleń stron per sesja
# - Kiedy ostatnia aktywność
# Wszystko bez odpytywania bazyGenServer vs tradycyjne podejścia
| Problem | Tradycyjnie | GenServer |
|---|---|---|
| Koszyk zakupowy | Tabela carts + cart_items, 3-5 zapytań per klik | Proces w pamięci, < 1 ms, zapis do bazy co N sekund |
| Rate limiting | Redis + TTL + Lua script | 50 linii kodu, zero zależności |
| Cache cennika | Redis + cron odświeżający | Proces z Process.send_after, auto-refresh |
| Sesja użytkownika | Redis/Memcached + TTL | Proces z timerem, auto-cleanup |
| Maszyna stanowa | Kolumna status + walidacja w serwisie | Proces z @transitions, niemożliwe złe przejścia |
| Kolejka zadań | RabbitMQ / Redis + Sidekiq | Oban (PostgreSQL) lub GenServer |
| Lock/mutex | SELECT FOR UPDATE lub Redis SETNX | Mailbox GenServera = naturalny mutex |
Mailbox = naturalny mutex
To jest subtelna, ale potężna cecha GenServera. Każdy proces BEAM ma mailbox - kolejkę wiadomości przetwarzanych jedna po drugiej. To eliminuje cały zestaw problemów współbieżności:
# Problem: dwóch handlowców jednocześnie rezerwuje ostatnią sztukę
# Tradycyjnie (bez locka):
# Handlowiec A: SELECT stock FROM products WHERE id = 1 → stock = 1
# Handlowiec B: SELECT stock FROM products WHERE id = 1 → stock = 1
# Handlowiec A: UPDATE products SET stock = 0 → OK
# Handlowiec B: UPDATE products SET stock = -1 → BUG! Stock ujemny!
# Z GenServerem:
defmodule MyApp.StockManager do
use GenServer
def reserve(product_id, quantity) do
GenServer.call(via(product_id), {:reserve, quantity})
end
@impl true
def handle_call({:reserve, quantity}, _from, stock) do
if stock >= quantity do
{:reply, {:ok, stock - quantity}, stock - quantity}
else
{:reply, {:error, :insufficient_stock}, stock}
end
end
end
# Handlowiec A: reserve(1, 1) → wiadomość w mailboxie
# Handlowiec B: reserve(1, 1) → wiadomość CZEKA w kolejce
# GenServer przetwarza A: stock = 1, quantity = 1 → {:ok, 0}
# GenServer przetwarza B: stock = 0, quantity = 1 → {:error, :insufficient_stock}
#
# Zero locków, zero race conditions, zero SELECT FOR UPDATE
# Mailbox = sekwencyjne przetwarzanie = naturalny mutexJak GenServery współpracują
Prawdziwy system to nie jeden GenServer - to sieć procesów, które komunikują się przez wiadomości:
Każdy prostokąt to osobny proces. Każdy ma swój stan, swoje reguły, swój lifecycle. Jeśli NotificationSender padnie - zamówienie nadal przejdzie. Supervisor zrestartuje wysyłkę powiadomień, a wiadomość zostanie ponowiona.
Registry - znajdź proces po nazwie biznesowej
Jak znaleźć GenServer koszyka dla użytkownika 42, jeśli w systemie jest 500 aktywnych koszyków?
# Registry pozwala adresować procesy po dowolnym kluczu
# Konfiguracja w Application supervisor:
children = [
{Registry, keys: :unique, name: MyApp.CartRegistry},
{Registry, keys: :unique, name: MyApp.OrderRegistry},
{Registry, keys: :unique, name: MyApp.SessionRegistry},
{DynamicSupervisor, name: MyApp.CartSupervisor, strategy: :one_for_one}
]
# Uruchomienie koszyka dla użytkownika:
def ensure_cart_started(user_id) do
case Registry.lookup(MyApp.CartRegistry, user_id) do
[{pid, _}] ->
{:ok, pid}
[] ->
DynamicSupervisor.start_child(
MyApp.CartSupervisor,
{MyApp.Cart, user_id}
)
end
end
# Teraz:
MyApp.Cart.add_item(user_id, product_id, 2)
# Registry automatycznie znajdzie proces koszyka tego użytkownika
# Bez bazy, bez Redisa, bez wyszukiwania - O(1) lookupKiedy używać GenServera, a kiedy bazy
GenServer nie zastępuje bazy danych. To inna warstwa - warstwa procesów biznesowych żyjących w pamięci:
| Sytuacja | Użyj GenServera | Użyj bazy danych |
|---|---|---|
| Dane muszą przetrwać restart serwera | ✓ | |
| Potrzebujesz natychmiastowej odpowiedzi | ✓ | |
| Dane muszą być dostępne z wielu serwisów | ✓ | |
| Obiekt ma timeout / lifecycle | ✓ | |
| Potrzebujesz naturalnego mutexa | ✓ | |
| Potrzebujesz raportów i analityki | ✓ | |
| Stan jest tymczasowy (sesja, koszyk) | ✓ | |
| Stan jest trwały (zamówienie, faktura) | ✓ + baza | ✓ |
Najlepsze podejście: GenServer + baza razem. GenServer jako warstwa szybkiego dostępu i logiki, PostgreSQL jako warstwa trwałości. GenServer zapisuje do bazy asynchronicznie, co N sekund lub przy ważnych zdarzeniach. Przy restarcie procesu - odtwarza stan z bazy.
Ile to kosztuje (pamięć)
# Jeden proces BEAM:
:erlang.process_info(self(), :memory)
#=> {:memory, 2688} - 2.6 KB
# Koszyk z 10 produktami: ~4-5 KB
# Sesja użytkownika: ~3 KB
# Rate limiter (100 wpisów): ~5 KB
# Maszyna stanowa zamówienia: ~4 KB
# 1000 aktywnych koszyków = ~5 MB
# 5000 sesji użytkowników = ~15 MB
# 100 rate limiterów = ~0.5 MB
# ─────────────────────────────
# Razem: ~20 MB
# Serwer z 32 GB RAM może obsłużyć
# MILIONY prostych procesów jednocześnieDla porównania: Redis z 1000 koszykami to osobny serwer, osobne utrzymanie, osobna awaria, serializacja/deserializacja przy każdym dostępie, opóźnienie sieciowe. GenServer to zero dodatkowej infrastruktury.
Co to oznacza dla biznesu
System tradycyjny: baza danych jest centrum wszystkiego. Każde kliknięcie = zapytanie SQL. 50 użytkowników jednocześnie = 200 zapytań/sekundę. Baza staje się wąskim gardłem. Rozwiązanie? Większy serwer. Cache w Redis. CDN. Każda warstwa to dodatkowy koszt i złożoność.
System z GenServerami: procesy biznesowe żyją w pamięci. Koszyk odpowiada w < 1 ms. Maszyna stanowa pilnuje reguł sama. Rate limiter działa bez Redisa. Baza danych obsługuje tylko zapisy - 10× mniej obciążona. Jeden serwer zamiast trzech.
| Metryka | Tradycyjny stack | Elixir + GenServer |
|---|---|---|
| Czas odpowiedzi (koszyk) | 20-50 ms | < 1 ms |
| Zapytania SQL per użytkownik/min | 30-60 | 2-5 |
| Zewnętrzna infrastruktura | Redis + baza | Tylko baza |
| Procesy z własnym lifecycle | Brak (cron joby) | Natywne |
| Obsługa 1000 użytkowników online | 3+ serwery | 1 serwer |
GenServer to nie jest „fajny feature Elixira". To sposób myślenia o architekturze, w którym procesy biznesowe są prawdziwymi obiektami w systemie - ze stanem, zachowaniem i cyklem życia. Zamówienie nie jest wierszem w tabeli. Jest żywym procesem, który wie, co wolno, a czego nie, i sam reaguje na zdarzenia.
Chcesz zobaczyć, jak GenServery mogą uprościć architekturę Twojego systemu? Porozmawiajmy - pokażemy model dopasowany do Twoich procesów biznesowych.