DDD i EDA w systemie IoT, w którym błąd zabija roślinę
Jak zaprojektowaliśmy huba do ochrony roślin przed mrozem: bounded contexts, event bus z priorytetami i silnik reguł jako czysta funkcja. Architektura, w której safety jest wymuszone strukturą, nie dobrą wolą.
W większości systemów źle postawiona granica między modułami kosztuje refaktor. W systemie, który w nocy decyduje, czy włączyć grzałkę pod palmą przy −12 °C, kosztuje martwą roślinę u klienta. To zmienia sposób, w jaki się projektuje — architektura przestaje być estetyką, a staje się mechanizmem bezpieczeństwa.
Poniżej trzy decyzje, które w naszym hubie (Go, lokalny-first, na Raspberry Pi) niosą najwięcej: bounded contexts z DDD, event bus z priorytetami z EDA, i silnik reguł jako czysta funkcja. Wszystkie sprowadzają się do jednego: każdą drogę do złego zachowania system ma odciętą strukturą, nie dyscypliną — złej decyzji trzeba by chcieć.
Bounded contexts: granica, której nie da się przypadkiem przekroczyć
Domenę pocięliśmy na pięć bounded contextów — pięć modeli w jednym binarium (modularny monolit na Pi), gdzie granica kontekstu to granica pakietu, nie procesu. Każdy ma własny ubiquitous language — własne pojęcia, których sąsiedni kontekst nie współdzieli (na granicy się je tłumaczy, nie przenosi):
- Device — fizyczne urządzenia i protokoły. Zna „kanał", „odczyt", nie zna „palmy".
- Station — stanowisko jako kontener logiczny. Most między fizycznym a domenowym.
- Monitoring — zbieranie i walidacja pomiarów (świeżość, sanity bounds).
- Protection — reguły ochrony: kiedy i czym grzać.
- Identity — konta i uprawnienia.
Reguła, której nie wolno naruszyć: Protection nie importuje niczego z Device.
Silnik decydujący o grzaniu nie wie, że po drugiej stronie jest przekaźnik Sonoff czy
Tasmota — dostaje odczyt w kontekście roli stanowiska i zwraca intencję:
// Granica Protection → Station: intencja, nie sterownik.
type ControlCommand struct {
StationID string
Action ControlAction // turnOn / turnOff / boost
Reason string
RequestedAt time.Time // UTC
}
Dzięki temu wymiana urządzenia to zmiana w jednym adapterze, a nie w logice ochrony.
Zależności biegną tylko do wewnątrz: infrastructure → application → domain. domain/
nie zna nikogo. To nie jest konwencja „na słowo": cyklu importów Go w ogóle nie skompiluje,
a jednokierunkowość warstw i regułę Protection↛Device domyka lint importów w CI
(depguard/arch-test) — złamanie to czerwony build, nie uwaga w review. Bo złamanie tej
granicy to pierwszy krok do sytuacji, w której refaktor sterownika psuje decyzję o grzaniu.
Event bus z priorytetami: nie każde zdarzenie jest równe
Event-driven architecture zwykle kojarzy się z „wszystko async, wszystko luźno spięte". W systemie, w którym spóźniona decyzja zabija roślinę, to za mało — bo decyzja o grzaniu nie może czekać w kolejce za zapisem do bazy. Nasz bus ma trzy pasma:
CRITICAL — synchronicznie, w goroutine publishera (µs)
pomiar → ocena reguł → komenda sterująca. Błąd propaguje się do publishera.
NORMAL — async, bufor 256, publish nieblokujący (ms)
zapis pomiaru, sesje grzania, alerty. Pełny bufor → drop zapisu (at-most-once).
LOW — async, bufor 64, drop policy
push do dashboardu (SSE). Klient nie nadąża? Dropujemy najstarsze.
Pasmo CRITICAL jest uczciwie nie-EDA: synchroniczny łańcuch z propagacją błędu, bliżej wywołania funkcji niż magistrali zdarzeń — i tak ma być, bo event-driven jest dopiero to, co może poczekać. Ścieżka przeżyciowa jest synchroniczna i pierwsza; NORMAL i LOW są async i celowo nieblokujące — gdy bufor zapisu się zapełni (padnięta baza), dropujemy pomiar, nigdy nie wstrzymujemy publishera. To świadomy trade-off: wolimy stracić punkt na wykresie (at-most-once) niż tick sterowania. Software'owy interlock przegrzania ustawia blokadę w tym samym „ticku", zanim silnik cokolwiek zdecyduje (gwarantuje to kolejność subskrybentów CRITICAL — interlock rejestrowany przed silnikiem) — ale to ostatnia linia obrony w kodzie, nie ostatnia w ogóle: żadna architektura nie przeżyje zwisu procesu, więc realnym bezpiecznikiem musi być sprzętowy failsafe (watchdog, termik) pod softwarem. To jest EDA użyte nie dla mody na async, lecz do rozdzielenia awarii: padnięcie bazy nie może zatrzymać sterowania.
Silnik reguł jako czysta funkcja: SOLID, który się opłaca
Najważniejszy element — silnik decydujący o grzaniu — jest czystą funkcją:
// Wejście → wyjście. Zero I/O, zero time.Now() w środku.
func Evaluate(
readings []StationReading,
rules []ControlRule,
states map[ControlRole]ControlState,
forecast map[string]float64,
at time.Time, // czas WSTRZYKIWANY, nigdy pobierany w środku
) []ControlCommand
To pojedyncza decyzja projektowa, która realizuje kilka zasad SOLID naraz:
- Single Responsibility — silnik decyduje, nie pobiera danych i nie wysyła komend.
- Dependency Inversion — a właściwie jej brak — silnik nie sięga po zegar, bazę ani sieć: czas, odczyty i stan dostaje jako argumenty. To nawet nie inwersja zależności od infrastruktury — on jej nie ma, więc nie istnieje konkret, który trzeba by mockować. Produkcja podaje realny czas; test — czas ustawiony ręcznie.
- Open/Closed — nowy scenariusz ochrony to nowa reguła (dana), nie zmiana silnika.
Trzy powyższe mieszkają w silniku. Czwarta — Liskov — pilnuje drugiego filaru:
kontraktu sterownika. LSP w ścisłym sensie mówi, że każdy podtyp musi honorować kontrakt
nadtypu; jego praktyczną konsekwencją jest wymienność rodzeństwa — każde urządzenie (Tasmota,
BleBox, Shelly… i FakeDriver) honoruje ten sam kontrakt telemetrii, emituje ten sam
kształt pomiaru i jako źródło danych podstawia się za każde inne. Core jest
napisany przeciw tej abstrakcji, ślepy na to, jakie urządzenie stoi po drugiej stronie.
To nie teoria:
FakeDriver jest w pełni substytuowalny za prawdziwy sterownik i dlatego cały tor — od
telemetrii po wykonanie komendy — testujemy end-to-end bez sprzętu. Sam silnik jest jeszcze
niżej: widzi wyłącznie []StationReading, więc sterowników w ogóle nie zna. Wydzielenie kontraktu
do jednej bramy telemetrii to konkretne miejsce, w którym ta wymienność jest egzekwowana:
gdyby któryś sterownik „oszukiwał" kontrakt, cała podstawialność by się sypnęła — więc
kontrakt jest granicą, nie sugestią.
Piąta — Interface Segregation — kosztowała refaktor, i to uczciwsza część tej historii.
Driver był początkowo za szeroki: łączył telemetrię (Start) ze sterowaniem (Send/Handles).
Czysty czujnik (BleBox, ESPHome, stacja pogodowa) był więc zmuszany implementować metody
sterujące, których nie używa — choćby jako puste zaślepki. Rozbiliśmy Driver na TelemetrySource
(każdy sterownik) i Controllable (tylko przekaźniki/HVAC): czujnik implementuje teraz sam
TelemetrySource, a wysyłanie komend zależy od wąskiego Controllable, nie od tłustego interfejsu.
Konsument zależy teraz dokładnie od tego, czego używa — wąska zależność zamiast tłustej.
Zysk jest namacalny: całą noc mrozu — spadek temperatury, awarię czujnika, przegrzanie — odtwarzamy deterministycznie: pojedynczy tick w mikrosekundy, całą noc w ułamku sekundy, bo wystarczy podać inne wejście i inny czas. Test „czy przy −8 °C grzałka rusza, a przy przekroczeniu progu gaśnie" nie potrzebuje ani urządzenia, ani czekania, ani prawdziwego zegara. A skoro potrafimy to przetestować tak tanio — testujemy każdą ścieżkę survival, zanim dotknie żywej rośliny.
Dlaczego pilnować wszystkich pięciu, a nie „większości"? Bo w systemie, w którym błąd = martwa roślina, każda zasada kupuje konkretną własność, nie punkt w recenzji: SRP i wstrzykiwanie wejść → testowalność (noc mrozu odtwarzana offline), Liskov → substytuowalność (fake zastępuje realny, więc harness w ogóle istnieje), Open/Closed → rozszerzalność bez dotykania rdzenia, ISP → wąskie zależności (zmiana sterownika nie przecieka do decyzji o grzaniu). Nie twierdzę, że złamanie którejś zasady = deterministyczna awaria — to byłby ten sam cargo-cult, przed którym przestrzegam. Twierdzę, że każda kupiła tu konkretną, sprawdzalną własność, więc „cztery z pięciu prawie spełnione" nie znaczy „80% bezpieczeństwa" — znaczy „jedną z tych własności właśnie oddaliśmy, świadomie albo nie". Dlatego zasadę spełnioną tylko „prawie" domyka się refaktorem, zanim dołoży się następną funkcję — nie potem.
Wniosek: architektura jako kontrakt bezpieczeństwa
Te trzy decyzje nie są niezależne. Bounded contexts gwarantują, że logika ochrony nie zależy od sprzętu. Event bus gwarantuje, że decyzja wyprzedza persystencję. Czysta funkcja gwarantuje, że decyzję da się w całości przetestować offline. Razem dają własność, o którą naprawdę chodzi: system, którego nie da się łatwo zmusić do złego zachowania — złą decyzję trzeba w tym kodzie wyrazić świadomie, przypadkiem się jej nie napisze.
Tak budujemy w E4T: architektura ma najpierw uniemożliwiać błędy, a dopiero potem wyglądać ładnie. W systemie, w którym stawką jest żywa roślina, to nie luksus — to wymóg.
