Krótka scenka z dyżuru SRE
Jest 2:37 w nocy. Płatności stoją, bo proces walidacji adresu wysłał w świat „nulla”. Hotfix naprawia objawy, ale nikt nie jest pewien, czy nie zepsuje czegoś obok. W podobnym zespole obok — z usługą napisaną w Haskellu — kompilator nie przepuściłby takiego kodu do wdrożenia, bo „nulla” w typach po prostu nie ma.
Brief: pytania, na które trzeba znać odpowiedź
- Jak Haskell i paradygmat funkcyjny redukują klasy błędów jeszcze przed uruchomieniem programu?
- Czy czystość funkcji i niezmienność faktycznie pomagają w niezawodności i współbieżności?
- W jaki sposób modelować efekty (I/O, błędy, stan), by nie rozlewały się po całym kodzie?
- Jak testować właściwości systemu zamiast przepisywać scenariusze z Jiry?
- Czy da się wymusić w kompilacji dodatkowe gwarancje (np. brak pustych list, zakresy wartości)?
- Jak Haskell wspiera izolowanie i kontrolę konkurencji (STM, Async, kolejki)?
- Kiedy Haskell ma sens w projektach high-availability, a kiedy lepiej wziąć inny język?
- Od czego zacząć, by nie przepalić budżetu na naukę i migrację?
1. Uszczelnij granice błędów typami zamiast konwencji
Model domeny przez typy algebraiczne
W wysokiej niezawodności pierwszym krokiem jest zamknięcie „pól minowych” w typach. Haskell oferuje typy algebraiczne (sumy i produkty), które pozwalają modelować dziedziny tak, by nie dało się wyrazić stanów niedozwolonych.
-- Przykład: pewna waluta i kwota > 0
newtype PLN = PLN { unPLN :: Int } -- grosze
newtype Amount = Amount { unAmount :: Int }
mkAmount :: Int -> Maybe Amount
mkAmount x | x > 0 = Just (Amount x)
| otherwise = Nothing
data PaymentMethod = Card CardToken | Blik BlikCode | Transfer Iban
data Payment = Payment
{ pmAmount :: Amount
, pmMethod :: PaymentMethod
, pmUserId :: UserId
}
Nawet jeśli ktoś spróbuje utworzyć „pustą” płatność, nie przejdzie przez smart constructor. Brak wartości reprezentujemy jawnie (Maybe/Either), a nie „magicznym” null.
Rozróżniaj etapy życia danych
Użyj oddzielnych typów dla faz: „niezweryfikowane” vs „zweryfikowane”. Dzięki temu kompilator przypilnuje, by np. w operacjach finansowych nie przeszły dane jeszcze nieprzetworzone.
newtype RawIban = RawIban Text
newtype ValidIban = ValidIban Text
validateIban :: RawIban -> Either ValidationError ValidIban
Nowe typy dla jednostek i ról
„Grosze” to nie to samo co „sekundy”. Dwie wartości Int można przypadkowo pomylić — dwa newtype’y już nie:
newtype Milliseconds = Ms Int
newtype Cents = Cents Int
delay :: Milliseconds -> IO ()
charge :: Cents -> UserId -> IO ()
Mini-wniosek: typy algebraiczne i newtype’y ograniczają przestrzeń błędów do tego stopnia, że całe klasy defektów znikają jeszcze przed runtime.
2. Postaw na czystość funkcji i niezmienność
Referencyjna przeźroczystość i deterministyczne testy
Funkcje czyste mają zawsze ten sam wynik dla tych samych argumentów i nie mają skutków ubocznych. Dzięki temu:

- testy stają się szybkie i deterministyczne,
- łatwiej używać cache i memoizacji,
- debugowanie to analiza danych wejściowych i kodu, a nie stanów globalnych.
-- Czysta walidacja, bez I/O:
validatePayment :: Payment -> Either ValidationError ValidPayment
Niezmienność upraszcza współbieżność
Brak modyfikowalnego stanu eliminuje potrzebę blokad w większości kodu. Gdy dane są niezmienne, współdzielenie polega na przekazywaniu referencji do niezmiennych struktur, a nie na „kto pierwszy ten lepszy”.
Separacja „czystego” i „brudnego” świata
Haskell wymusza izolację efektów poprzez typ IO. Reguła projektowa: maksymalnie dużo logiki w czystym kodzie, „na brzegu” cienka warstwa I/O. Dzięki temu funkcje domenowe nie zależą od bazy czy sieci i są łatwe do sprawdzenia.
Mini-wniosek: czystość i niezmienność to tańsze testy, mniej blokad i mniej „wyścigów” w nocy.
3. Efekty pod kontrolą: Either, ExceptT, Reader, State
Sygnalizowanie błędów typem zamiast wyjątkami
W projektach o wysokiej niezawodności błąd to wartość. Zamiast „rzucać”, funkcja zwraca Either. To zmusza autora wywołania do obsługi błędu — kompilator nie przepuści pominięcia.
type AppM = ReaderT Env (ExceptT AppError IO)
runPayment :: PaymentRequest -> AppM PaymentId
runPayment req = do
valid <- withExceptT ValidationError (validate req)
user <- findUser (prUserReq req) -- Reader: dostęp do Env
charge <- bill user valid -- ExceptT: kontrolowane błędy
pure (paymentId charge)
Wstrzykiwanie zależności typem Reader
ReaderT pozwala przekazać konfigurację i uchwyty do serwisów bez globalnego stanu. To typowa „szyna” w aplikacjach Haskella: ReaderT Env (ExceptT e IO).
Stan lokalny bez wycieków
Jeśli stan jest konieczny, kapsułuj go w State lub w kontrolowanym IORef/STM. Nigdy nie „rozwijaj” stanu po całym kodzie.
Mini-wniosek: jawny podpis funkcji (z efektami) to kontrakt, który nie pozwala dyskretnie wprowadzić niekontrolowanych wyjątków i ukrytych zależności.
4. Testuj właściwości, a nie przypadki: QuickCheck/Hedgehog
Generatory i inwarianty
Property-based testing pozwala zwięźle wyrazić reguły, które mają obowiązywać dla szerokiego zakresu danych. Zamiast dziesiątek przypadków testowych — generator i kilka asercji właściwości.
prop_totalAmountNonNegative :: Property
prop_totalAmountNonNegative =
property $ do
payments <- forAll genValidPayments
total payments >= 0 === True
Idempotencja, łączność, odwracalność
W niezawodnych systemach krytyczne są własności: idempotencja (powtórzenie operacji nie zmienia wyniku), łączność (zachowanie przy agregacji) czy odwracalność (dekodowanie po kodowaniu).
prop_encodeDecodeRoundtrip :: Value -> Bool
prop_encodeDecodeRoundtrip v = decode (encode v) == Just v
Minimalne przykłady błędów
QuickCheck i Hedgehog minimalizują (shrink) dane, gdy test zawiedzie. Otrzymujesz możliwie najprostszy przykład, który łamie regułę — idealne do szybkiej diagnozy.
Mini-wniosek: właściwości to „sieć bezpieczeństwa” nad dynamicznie zmieniającą się domeną, pewniejsza niż zestaw ręcznie dobranych przypadków.
5. Gwarancje kompilacyjne: „NonEmpty”, LiquidHaskell, totalność
Eliminuj częściowe funkcje
„Head” pustej listy? W niezawodnych projektach to niedopuszczalne. Użyj typów, które blokują takie sytuacje:

import Data.List.NonEmpty (NonEmpty(..))
safeHead :: NonEmpty a -> a
safeHead (x :| _) = x
Kontrakty i typy wzbogacone
LiquidHaskell dodaje „typy udoskonalone” przez predykaty (refinement types). Możesz opisać warunki wartości i sprawdzać je na etapie kompilacji.
{-@ type Positive = {v:Int | v > 0} @-}
{-@ mkAmountLH :: Positive -> Amount @-}
mkAmountLH :: Int -> Amount
mkAmountLH x = Amount x
Wymuszaj kompletność dopasowań
Włącz ostrzeżenia GHC o niepełnym pattern matchingu i traktuj je jak błędy. Dzięki temu każda nowa gałąź w typie sumy zmusi Cię do przemyślenia konsekwencji w całym kodzie.
Mini-wniosek: kompilator niech będzie bramkarzem, który nie wpuszcza wątpliwych piłek na boisko produkcji.
6. Współbieżność przewidywalna: STM, Async i backpressure
Transakcje pamięciowe zamiast blokad
STM (Software Transactional Memory) w Haskellu pozwala pisać sekcje krytyczne jak transakcje: atomowo, bez ręcznych blokad i bez ryzyka zakleszczeń przy poprawnej kompozycji.
Async bez zombie wątków: withAsync, link, race
W nocy padł serwis autoryzacji, a w workersach zostały „osierocone” wątki. To klasyk przy ręcznej orkiestracji. Użyj prymitywów z async, które dają strukturę i propagację błędów.
import Control.Concurrent.Async (withAsync, link, race, waitAnyCancel)
import Control.Concurrent.STM
worker :: TBQueue Job -> IO ()
worker q = forever $ do
job <- atomically (readTBQueue q)
process job
run :: TBQueue Job -> IO ()
run q =
withAsync (worker q) $ a1 ->
withAsync (worker q) $ a2 -> do
link a1 -- propaguj wyjątki do rodzica
link a2
-- czekaj na pierwszy błąd i anuluj pozostałe
_ <- waitAnyCancel [a1, a2]
pure ()
Praktycznie: withAsync dba o sprzątanie, link o to, by błąd nie zniknął w logach, a race pozwala wybrać „zwycięzcę” (np. cache lub sieć) i anulować resztę.
Kolejki z ciśnieniem zwrotnym: TBQueue zamiast „niech leci”
Jeśli producent jest szybszy niż konsument, to albo zagłodzisz RAM, albo zjesz opóźnienia. Bounded kolejki w STM wymuszają backpressure.
import Control.Concurrent.STM
setup :: Int -> IO (TBQueue Job)
setup cap = atomically (newTBQueue (fromIntegral cap))
producer :: TBQueue Job -> [Job] -> IO ()
producer q jobs = forM_ jobs $ j ->
atomically (writeTBQueue q j) -- zablokuje się, gdy pełna
consumer :: TBQueue Job -> IO ()
consumer q = forever $ do
j <- atomically (readTBQueue q)
handleJob j
Jeżeli nie możesz blokować producenta (np. przyjmujesz ruch z Internetu) — zamiast porzucać dane po cichu, odrzuć świadomie z kodem 429 i metryką.
Timeouty, anulowanie i retry z limitem
Długi czas odpowiedzi partnera zjada pule wątków. Nakładaj budżety czasu i przerwij działanie elegancko.
import System.Timeout (timeout)
import Control.Concurrent.Async (race)
callPartner :: IO Result
safeCall :: IO (Maybe Result)
safeCall = timeout (3 * 1000000) callPartner -- 3s
-- alternatywnie
firstWins :: IO (Either A B)
firstWins = race fastLocal slowRemote
import Control.Retry
policy = limitRetries 3 <> exponentialBackoff 100000
resilient :: IO Result
resilient = recovering policy [const $ Handler isTransient] (const callPartner)
Retry musi mieć limit i warunek; brak jednej z tych rzeczy to proszenie się o lawinę.
Brak przecieków zasobów: bracket i maskowanie przerwań
W świecie wyjątków asynchronicznych zwalnianie zasobów „po try/finally” robi bracket. To obowiązek przy plikach, socketach, połączeniach DB.
import Control.Exception (bracket)
withConn :: (Conn -> IO a) -> IO a
withConn = bracket openConn closeConn
use :: IO ()
use = withConn $ c -> do
-- nawet jeśli tu poleci wyjątek, closeConn się wykona
send c payload
Mini-wniosek: połączenie STM + Async + kontrola czasu i zasobów daje przewidywalną współbieżność bez tarcia i „dziur” pamięci.
7. Interfejsy typowane: Servant, Aeson i ewolucja protokołów
Nowy partner wdrożył „niewinne” pole w JSON i nagle parsowanie w prodzie zaczęło wracać 500. Najwięcej błędów czai się na granicach — dlatego je typujemy.
API jako typ, nie jako wiki
{-# LANGUAGE DataKinds, TypeOperators #-}
import Servant
type PaymentsAPI =
"payments" :> ReqBody '[JSON] PaymentReq :> PostCreated '[JSON] PaymentId
:<|> "payments" :> Capture "pid" PaymentId :> Get '[JSON] Payment
server :: Server PaymentsAPI
server = createPayment :<|> getPayment
Zmiana w trasie lub modelu — brak kompilacji. Ta sama definicja służy serwerowi i klientowi, co obniża ryzyko „dryfującej” dokumentacji.
Serializacja defensywna i wersjonowanie
data PaymentReqV1 = ...
data PaymentReqV2 = ...
data PaymentReqWire = V1 PaymentReqV1 | V2 PaymentReqV2
instance FromJSON PaymentReqWire where
-- dekoder przyjmuje kilka kształtów; dodanie V3 nie psuje V1/V2
encodeStable :: DomainPayment -> PaymentReqWire
- Oddziel model domenowy od „drutu” (wire). Nie mieszaj kompromisów protokołu z logiką.
- Dodawaj pola jako opcjonalne; usuwanie jest trudniejsze niż ignorowanie nadmiaru.
- Round-trip testy na kontrakt: dekodowanie–enkodowanie nie gubi informacji.
prop_roundtripWire :: DomainPayment -> Bool
prop_roundtripWire d = decode (encode (encodeStable d)) == Just (encodeStable d)
Idempotencja na styku sieci i porządne retry
newtype IdemKey = IdemKey Text
createPayment :: IdemKey -> PaymentReq -> AppM PaymentId
createPayment k req = do
-- unikalny indeks po (IdemKey)
existing <- findByKey k
case existing of
Just pid -> pure pid
Nothing -> do
pid <- process req
persistKey k pid -- UPSERT
pure pid
Łącz to z retry tylko dla błędów tymczasowych (sieć, 5xx), nigdy dla błędów biznesowych (4xx). Inaczej powielisz skutki.
8. Narzędzia i praktyki zespołowe, które robią różnicę
Mały zespół dorzucił Haskell do monorepo i po tygodniu zderzył się z buildami i stylem. Kilka drobiazgów robi ogromną różnicę w ergonomii.
Szybkie sprzężenie zwrotne: ghcid, -Wall i budżet ostrzeżeń
-- ghci na sterydach: przebudowuje po zapisie pliku
ghcid --command="cabal repl app:exe-name" --warnings
- Włącz
-Wall -Wcompat -Werrorna CI, lokalnie możesz luzować. - Pilnuj pełnych dopasowań i nieużywanych importów — to realne błędy jutra.
Styl i higiena: formatter i statyczna analiza
- Formatter bez dyskusji:
ormolulubfourmolu. hlintdo prostych refaktorów,weederna martwy kod.- Pre-commit: format + lint + szybkie testy właściwości krytycznych.
Zarządzanie zależnościami i powtarzalny build
ghcupdo wersji GHC i narzędzi,cabalzcabal.project.freezedla deterministycznych buildów.- Jeśli infra na to pozwala — hermetyzacja przez Nix/Flakes.
9. Testy własności zamiast katalogu przypadków: QuickCheck/Hedgehog
Po incydencie w bankowości mobilnej audyt poprosił o dowody, że „to już się nie powtórzy”. Zamiast dopisywać setki przykładów, zespół zbudował siatkę własności, które muszą zawsze trzymać inwarianty. Błędy wracały szybciej na CI niż do użytkowników.
Formułuj reguły, nie przykłady
Zamiast „dla wejścia X zwróć Y”, zapisz co ma być zawsze prawdą: idempotencja, zachowanie sum, stabilność sortowania.
import Test.QuickCheck
import Data.List (sort, nub)
-- sortowanie jest idempotentne
prop_sortIdem :: [Int] -> Bool
prop_sortIdem xs = sort (sort xs) == sort xs
-- deduplikacja nie zmienia się przy powtórnym użyciu
prop_nubIdem :: [Int] -> Bool
prop_nubIdem xs = nub (nub xs) == nub xs
Mini-wniosek: własność testuje nieskończoną rodzinę przypadków, przykłady — tylko to, co akurat pamiętasz.
Generatory z ograniczeniami domenowymi
Wiele reguł obowiązuje tylko dla sensownych danych. QuickCheck i Hedgehog pozwalają generować je z filtrami i konstruktorami „smart”.
import qualified Data.List.NonEmpty as NE
import Test.QuickCheck
-- zawsze niepusta lista
prop_minLeHead :: NonEmptyList Int -> Bool
prop_minLeHead (NonEmpty xs) =
let ys = NE.fromList xs
in minimum xs <= NE.head ys
-- dzielenie tylko przez niezero
newtype NonZero = NonZero Int deriving Show
instance Arbitrary NonZero where
arbitrary = NonZero . getNonZero <$> arbitrary
Mini-wniosek: dobre generatory są jak kontrola jakości na wejściu — filtrują śmieci i przyspieszają realne wykrywanie błędów.
Shrinking i pokrycie — diagnoza, nie tylko czerwony krzyżyk
Własności bez „shrinkingu” i pokrycia często zostawiają Cię z przypadkowym, trudnym wejściem. Dodaj kryteria.
prop_dedupeKeepsOrder :: [Int] -> Property
prop_dedupeKeepsOrder xs =
checkCoverage $
cover 30 (length xs > length (nub xs)) "miały się zdarzyć duplikaty" $
let ys = nub xs
in all (x -> elem x xs) ys -- prosty kontrakt zachowania elementów
Mini-wniosek: kiedy test pęka, chcesz dostać minimalny kontrprzykład i informację, czy w ogóle dotknąłeś trudnych gałęzi danych.
10. Modelowanie domeny jako zbroja: newtype, smart konstruktory, phantom types
W środę klient wysłał „email” w polu telefonu; produkcja przyjęła, a downstream spadł dopiero przy notyfikacji. Typ, który nie pozwala skonstruować złej wartości, ucina błąd już przy wejściu.
newtype zamiast aliasów — zamień klasy błędów na błędy kompilacji
Podczas migracji do nowego PSP ktoś pomylił UserId z PaymentId. UUID-e są „kompatybilne”, tylko że zgoda poszła nie tam. Kompilator może to zatrzymać.
{-# LANGUAGE DerivingStrategies, GeneralizedNewtypeDeriving #-}
import Data.Aeson (ToJSON, FromJSON)
import Data.Text (Text)
import Data.UUID (UUID)
newtype UserId = UserId UUID
deriving stock (Eq, Ord, Show)
deriving newtype (ToJSON, FromJSON)
newtype PaymentId = PaymentId UUID
deriving stock (Eq, Ord, Show)
deriving newtype (ToJSON, FromJSON)
-- kompilacja odmówi:
-- sendReceipt :: PaymentId -> IO ()
-- sendReceipt (UserId u) = ...
Praktyczny sens: mylisz typy — nie ma deployu. JSON i logi wciąż proste dzięki deriving newtype.
Smart konstruktory i walidacja na krawędzi
Webhook zwrócił „email”: "admin at example dot com". Parser puścił, błąd wrócił z innego systemu po godzinie. Waliduj przy tworzeniu.
import qualified Data.Text as T
import Data.Aeson
newtype Email = Email { unEmail :: Text } deriving Show
data ValidationError = InvalidEmail deriving Show
mkEmail :: Text -> Either ValidationError Email
mkEmail t | "@" `T.isInfixOf` t = Right (Email t)
| otherwise = Left InvalidEmail
instance FromJSON Email where
parseJSON = withText "Email" $ t ->
either (const (fail "invalid email")) pure (mkEmail t)
sendWelcome :: Email -> IO ()
Praktyczny sens: nie ma „pół-poprawnych” wartości w środku systemu. Źle sformatowane dane zatrzymujesz tam, gdzie powstały.
Phantom types do modelowania stanów i uprawnień
„Zatwierdź płatność” dostało obiekt, którego nie przeszło AML. Niech typ zabroni takiego wywołania.
{-# LANGUAGE DataKinds, KindSignatures #-}
data Draft
data Approved
data Payment s = Payment { amount :: Int }
approve :: Payment Draft -> IO (Payment Approved)
approve p = pure (Payment (amount p))
capture :: Payment Approved -> IO ()
capture = const (pure ())
-- compile error: wymagany Approved
-- capture = capture (Payment 100 :: Payment Draft)
Praktyczny sens: do funkcji „ostrych” nie podasz obiektu w złym stanie. Sekwencja kroków jest kodem, nie checklistą w wiki.
Tagowane identyfikatory, żeby nie pomylić encji
W jednej bazie jest users i orders; oba mają kolumnę id. Mylenie ich to klasyk.
{-# LANGUAGE GeneralizedNewtypeDeriving, DerivingStrategies #-}
import Data.UUID
import Data.Aeson
newtype Id a = Id UUID
deriving stock (Eq, Ord, Show)
deriving newtype (ToJSON, FromJSON)
data User
data Order
type UserId = Id User
type OrderId = Id Order
loadOrder :: OrderId -> IO ()
notifyUser :: UserId -> IO ()
Praktyczny sens: interfejsy API i zapytania SQL są samodokumentujące, a kategorie błędów znikają jeszcze w IDE.
Jednostki i kwoty: unikaj „gołych” liczb
Kurs walutowy pomnożono przez kwotę netto zamiast brutto. Jeden Double wygląda jak drugi.
import Data.Int (Int64)
import Data.Time (NominalDiffTime)
newtype Cents = Cents Int64 deriving (Eq, Ord, Show)
newtype USD = USD Cents deriving (Eq, Ord, Show)
addUSD :: USD -> USD -> USD
addUSD (USD (Cents a)) (USD (Cents b)) = USD (Cents (a + b))
-- konwersja tylko na krawędzi (np. z formularza)
parseUSD :: Double -> Maybe USD
parseUSD d | d >= 0 = Just . USD . Cents . round $ d * 100
| otherwise = Nothing
-- czas jako typ, nie liczba sekund
timeoutBudget :: NominalDiffTime
timeoutBudget = 2.5
Praktyczny sens: mnożysz zgodne wielkości i nie gubisz centów przez błędy binarne. Konwersje z Double są świadome i odseparowane.
11. Efekty uporządkowane: ReaderT, ExceptT i bezpieczne IO
Po wdrożeniu alerty zasypały Slacka: część błędów „zniknęła”, bo ktoś połknął wyjątek w głębi IO. Efekty trzeba ułożyć tak, by błędy miały jeden tor, a zasoby były zwalniane.
Jedno AppM i wstrzykiwanie zależności przez ReaderT
Konfiguracja, logowanie, klient DB — zamiast globalnych zmiennych trzymaj je w Env i podawaj w monadzie aplikacyjnej.
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
import Control.Monad.Reader
import Control.Monad.Except
data Env = Env { dbPool :: Pool, logger :: Text -> IO () }
data AppErr = transient Text | business Text deriving Show
newtype AppM a = AppM { unAppM :: ReaderT Env (ExceptT AppErr IO) a }
deriving newtype (Functor, Applicative, Monad, MonadIO, MonadReader Env, MonadError AppErr)
runAppM :: Env -> AppM a -> IO (Either AppErr a)
runAppM env (AppM m) = runExceptT (runReaderT m env)
Praktyczny sens: funkcje nie sięgają po „magiczne” IO; testujesz je, podając sztuczne Env.
Rozdziel biznes od awarii: ExceptT na reguły, wyjątki na katastrofy
„Brak środków” to nie wyjątek systemowy. „Nie mogę otworzyć socketu” — tak.
charge :: Amount -> AppM PaymentId
charge amt = do
ok <- checkBalance amt
if ok then Right <$> persistCharge amt
else throwError (business "insufficient funds")
safeIO :: IO a -> AppM a
safeIO act = do
r <- liftIO (tryAny act)
either (throwError . transient . tshow) pure r
Praktyczny sens: komunikaty dla klienta i retry polityki działają przewidywalnie, bo klasy błędów są jawne.

Zarządzanie zasobami z MonadUnliftIO zamiast „ręcznego” finally
W wątku poleciał wyjątek, połączenie do DB zostało „wypożyczone na zawsze”. Unikaj tego.
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
import UnliftIO (MonadUnliftIO, withRunInIO)
import UnliftIO.Exception (bracket)
import qualified Data.Pool as pool
-- dorzuć do AppM:
-- deriving newtype (MonadUnliftIO)
withDb :: MonadUnliftIO m => pool.Pool Conn -> (Conn -> m a) -> m a
withDb p use =
withRunInIO $ run ->
pool.withResource p (run . use)
-- dowolny zasób: open/close zawsze w parze
withFileText :: MonadUnliftIO m => FilePath -> (Handle -> m a) -> m a
withFileText path use =
withRunInIO $ run -> bracket (openFile path ReadMode) hClose (run . use)
Praktyczny sens: znikają „wiszące” zasoby po wyjątkach i przerwaniach; interfejs jest czysty w AppM, a nie w surowym IO.
Idempotentne retry z budżetem czasu i klasyfikacją błędów
Gateway kartowy uniósł brwi i oddał 502. Jedno „spróbuj jeszcze raz” zrobiło podwójny capture. Brak klucza idempotencji.
import Control.Retry
import qualified Data.Text as T
import UnliftIO.Timeout (timeout)
newtype IdemKey = IdemKey T.Text deriving (Eq, Ord, Show)
data Transient = Net Text | Http5xx Int deriving Show
retryHttp :: RetryPolicyM m
retryHttp = limitRetries 4 <> exponentialBackoff 50_000
chargeIdem :: Amount -> IdemKey -> AppM PaymentId
chargeIdem amt key = do
let act = callGateway amt key -- gwarantuj idempotencję po stronie zewnętrznej
e <- timeout 2_000_000 $ -- 2s budżetu na całość
recovering retryHttp
[const $ Handler isTransient] -- tylko błędy ulotne
(const act)
maybe (throwError (transient "timeout")) pure e
isTransient :: Monad m => SomeException -> m Bool
isTransient ex = pure (isNet ex || is5xx ex)
Praktyczny sens: ponawianie nie mnoży efektów ubocznych; wiesz, kiedy odpuścić (timeout) i które błędy kwalifikują się do retry.
Koordynacja z asynchronicznością bez wycieków
Importer CSV uruchomił trzy równoległe zadania, jedno padło, dwa pozostały bez nadzoru. Potem był już tylko throttling i długi wieczór.
import UnliftIO.Async
parallelImport :: [FilePath] -> AppM ()
parallelImport files = do
-- failure w dowolnym dziecku anuluje resztę
withRunInIO $ run -> withAsync (mapConcurrently_ (run . processOne) files) wait
processOne :: FilePath -> AppM ()
processOne fp = bracket (openResource fp) closeResource useResource
Praktyczny sens: brak „zombie” zadań i deterministyczne sprzątanie przy anulacjach.
12. STM i kolejki transakcyjne: mniej blokad, więcej gwarancji
Limit przepustowości miał trzymać 100 req/s, ale przy piku wątki zjadały się nawzajem na blokadach. Jeden MVar nie robi systemu odpornym.
Rate-limiter jako prosty licznik tokenów
Token bucket w STM jest krótki i pewny: albo rezerwujesz atomowo, albo czekasz.
import Control.Concurrent.STM
data Bucket = Bucket { tokens :: TVar Int, capacity :: Int }
newBucket :: Int -> IO Bucket
newBucket cap = atomically $ Bucket <$> newTVar cap <*> pure cap
acquire :: Bucket -> Int -> IO ()
acquire b n = atomically $ do
t <- readTVar (tokens b)
if t >= n then writeTVar (tokens b) (t - n)
else retry -- wstrzymaj transakcję do czasu zmian
refill :: Bucket -> Int -> IO ()
refill b n = atomically $ do
t <- readTVar (tokens b)
writeTVar (tokens b) (min (capacity b) (t + n))
Praktyczny sens: zero wyścigów; backpressure zamiast eksplozji wątków.
Priorytety i fallbacki z orElse
Dwóch klientów bije do jednego zasobu; premium ma wygrać, reszta ma czekać lub iść na kolejkę „zimną”.

import Control.Concurrent.STM.TQueue
dequeuePref :: TQueue Job -> TQueue Job -> IO Job
dequeuePref qPremium qStd = atomically $
readTQueue qPremium `orElse` readTQueue qStd
Praktyczny sens: czytelne reguły arbitrażu bez ręcznego tasowania blokad.
13. Totalność zamiast pułapek: NonEmpty, Maybe i brak „partiali”
Raz w miesiącu ETL padał na pustej liście, bo ktoś zrobił head w „niemożliwym” miejscu. Niemożliwe zdarza się w piątki po 17.
NonEmpty i totalne API zamiast wyjątków w runtime
import qualified Data.List.NonEmpty as NE
avg :: NE.NonEmpty Int -> Double
avg xs = fromIntegral (sum xs) / fromIntegral (NE.length xs)
-- konwersja tylko tam, gdzie masz gwarancję
fromListNE :: [a] -> Maybe (NE.NonEmpty a)
fromListNE = NE.nonEmpty
Praktyczny sens: nie masz jak wywołać funkcji z pustą listą — kompilator pilnuje kontraktu.
Wycinaj „partial functions” i wymuszaj kompletność wzorców
-- w pliku .cabal lub ghci:
-- ghc-options: -Wall -Werror -Wincomplete-patterns -Wpartial-fields
safeHead :: [a] -> Maybe a
safeHead [] = Nothing
safeHead (x:_) = Just x
data State = New | Paid | Shipped
next :: State -> Maybe State
next New = Just Paid
next Paid = Just Shipped
next Shipped = Nothing
Praktyczny sens: brak „niespodzianek” produkcyjnych; braki w dopasowaniu wychodzą przy kompilacji.
Walidacja zakresów typem: porty, procenty, kwoty
newtype Port = Port Word16 deriving (Eq, Show)
mkPort :: Int -> Either Text Port
mkPort n | 0 <= n && n <= 65535 = Right (Port (fromIntegral n))
| otherwise = Left "port out of range"
Praktyczny sens: reguły domenowe nie wiszą w komentarzach, tylko w konstruktorach.
14. Testy własności: niezmienniki zamiast setek przykładów
Raz poprawiono błąd zaokrągleń, a tydzień później rozjechały się kwoty w JSON-ie na integracji mobilnej. Kilkadziesiąt unit testów z konkretnymi przykładami nie złapało rogu przypadków, który wygenerował użytkownik.
Roundtripy i niezmienniki domenowe
Najpierw pokryj warstwy graniczne: serializację i parsery. Własności mówią „dla każdego wejścia spełnionego przez generator”, a nie „dla tych trzech przykładów z Jira”.
Generatory i roundtripy w praktyce
Przy refaktorze serializacji kwot JSON zaczął „połykać” zera po przecinku. Nikt nie zauważył, bo testy miały dwa przykłady „19.99” i „0.01”. Własność roundtripowa gasi taki ogień zanim kod dotrze na staging.






