38 linii kodu ułatwiających walidację danych w Scali
Pracując ze skomplikowaną domeną biznesową często koniecznością staje się przeprowadzenie złożonego procesu walidacji danych. W tym artykule pokażę, jak rozwiązać problem z walidacją danych na zagnieżdżonych monadach, kiedy ilość kodu nadmiernie rośnie i trudno wyizolować odpowiednie poziomy abstrakcji. Proponowane rozwiązanie – 38 linijek kodu, które równie dobrze mogą być mikro-biblioteką, jest jednocześnie dobrym przykładem działania różnych wzorców programowania funkcyjnego: monad transformers, type classes i tagless final.
Jakub Dzikowski. Full stack developer w SoftwareMill, z ponad dziesięcioletnim doświadczeniem. Obecnie pracuje głównie w językach Scala i JavaScript. Dużą wagę przywiązuje do miękkich aspektów procesu wytwarzania oprogramowania. Autor blogów na dzikowski.github.io, oraz artykułów publikowanych na Medium.
Problem
Załóżmy, że pracujemy z kodem aplikacji zbudowanej na podstawie standardowej architektury, z repozytoriami (repositories) i usługami (services). Chcemy zapisać użytkownika (obiekt klasyUser
).
def saveUser(name: String, age: Int, country: String): Future[User] = repository.putUser(User(name, age, country))
Na razie implementacja jest stosunkowo prosta. Na potrzeby przykładu użyłem scalowego typuFuture
. Są oczywiście typy monadyczne, które mogą się sprawdzić lepiej, aleFuture
jest prawdopodobnie znane przez wszystkich, którzy programują w Scali i świetnie spełnia potrzeby przykładu.
Nagle pojawia się nowe wymaganie biznesowe. Powinniśmy mieć możliwość zapisania użytkownika tylko wtedy, gdy nie ma jeszcze użytkownika o takiej samej nazwie. Musimy wprowadzić walidację i obsługę błędów. Kod ewoluuje:
def saveUser(name: String, age: Int, country: String): Future[Either[ValidationError, User]] = repository.findUser(name).flatMap { case Some(_) => Future.successful(Left(UserAlreadyExists(name))) case None => repository.putUser(User(name, age, country)).map(u => Right(u)) }
Dobrą praktyką jest, by wyjątki zarezerwowane były rzeczywiście dla wyjątkowych sytuacji. Dlatego w Scali raczej nie wyrzuca się wyjątków, tylko korzysta z monadyEither
lubTry
(ja preferuję tę pierwszą). Dodatkowo, na potrzeby przykładu stworzony został jeszcze sealed trait ValidationError
, a także odpowiednie klasy, reprezentujące poszczególne rodzaje błędów walidacji (na przykład UserAlreadyExists
).
Okazuje się jednak, że to ciągle nie wszystkie wymagania biznesowe. Aplikacja musi przeprowadzać walidację na podstawie wieku użytkownika. Klient ma oddzielną usługę, która sprawdza, czy wiek użytkownika jest odpowiedni dla danego państwa. Dodatkowo, nazwa użytkownika musi składać się przynajmniej z dwóch znaków.
Poniżej podaję przykład zmodyfikowanej metodysaveUser
,uwzględniającej wszystkie te wymagania:
def saveUser(name: String, age: Int, country: String): Future[Either[ValidationError, User]] = if (name.length >= 2) repository.findUser(name).flatMap { case Some(_) => Future.successful(Left(UserAlreadyExists(name))) case None => ageService.isAgeValid(age, country).flatMap { case false => Future.successful(Left(InvalidAge(age, country))) case true => repository.putUser(User(name, age, country)).map(u => Right(u)) } } else Future.successful(Left(InvalidName(name)))
Takiego rodzaju kod bardzo trudno się analizuje, rozszerza i testuje. Tak naprawdę wymieszane są tutaj różne warstwy abstrakcji: reguły walidacji, odwołania do innych usług i bazy danych. Brakuje odpowiedniego rozdzielenia odpowiedzialności.
Możemy próbować jakoś ten kod zrefaktoryzować. Na przykład zaimplementować metody pomocnicze, które ukryją część niskopoziomowej logiki. Nawet jeśli po tym kod będzie wyglądać nieco lepiej, nie będzie wcale prostszy. Nie uda się uniknąć zagnieżdżania logiki walidacji.
- Najpierw konieczne jest zwalidowanie długości nazwy użytkownika. Nie ma potrzeby, by odwoływać się do bazy danych, jeśli ta nazwa jest za krótka. Tym samym nie uda się uniknąć jakiejś formy instrukcji warunkowej widocznej na przykładzie.
- Nie ma potrzeby odwoływać się do usługi weryfikującej wiek, jeśli użytkownik o takiej samej nazwie już istnieje. I tak już wystąpił błąd walidacji.
Kiedy pojawią się kolejne wymagania biznesowe, trzeba będzie poświęcić mnóstwo czasu na analizę aktualnej logiki walidacji, a kod, który powstanie, będzie najprawdopodobniej jeszcze bardziej skomplikowany. Aby uprościć ten kod, nie wystarczy standardowy refaktoring — należy dokonać przeskoku i sięgnąć do odpowiednich wzorców projektowych programowania funkcyjnego.
Zagnieżdżone monady
Walidacja w przedstawionym przykładzie przeprowadzana jest sekwencyjnie. Jest tylko jedna ścieżka:
- Jeśli nazwa użytkownika jest poprawna, upewnij się, że użytkownik o takiej nazwie jeszcze nie istnieje.
- Jeśli użytkownik nie istnieje, sprawdź, czy wiek jest poprawny.
- Jeśli wiek jest poprawny, zapisz użytkownika w bazie.
Czy dałoby się tak wykorzystać monadę Either
, żeby przeprowadzić walidację jako sekwencję kroków?Either
jako monada umożliwia wykonywanie akcji wewnątrzRight
, będącego odpowiednikiem poprawnego rezultatu.
Niestety, nie jest to możliwe. Konieczne jest uwzględnienie kolejnego poziomu monad, ponieważ w rzeczywistości mamy do czynienia z typemFuture[Either[F, S]]
. (Future
tak naprawdę jest tylko monadą w pewnych okolicznościach — w przypadku leniwej ewaluacji – jednak w wielu przypadkach zachowuje się jak monada).
Wykorzystanie zagnieżdżonych monad (w tym wypadku Future[Either[F, S]]
) jako zwracanego typu dla każdego kroku walidacji to krok w dobrym kierunku. Co jednak jest problematyczne, aby przeprowadzić walidację, należy każdorazowo przeprowadzić operację wewnątrz obu monad, na najniższym poziomie. Aby to zrobić, potrzeba sporo dodatkowego kodu i funkcji pomocniczych. Najlepsze, co udało mi się wypracować, można znaleźć w plikuUserServiceBetterLegacy.scala
w repozytorium z przykładami dla tego artykułu. Takiemu rozwiązaniu jednak daleko do tego, co można osiągnąć przez wprowadzenie nowej abstrakcji.
W tym miejscu dobrze by było sformalizować problem, który się tutaj pojawia. Konieczne jest przeprowadzenie operacji na zagnieżdżonych monadach jednocześnie, co wymaga znacznej ilości mało czytelnego kodu. Ponieważ jest to dość ogólny problem, więc:
- wydaje się dobrym pomysłem, by wydzielić nową abstrakcję, by go rozwiązać (funkcję, obiekt, bibliotekę itp.),
- prawdopodobnie społeczność skoncentrowana wokół Scali już sobie z tym w jakiś sposób poradziła i istnieje rozwiązanie (tak, istnieje, ale o tym za chwilę).
Najlepiej by było, gdyby zagnieżdżone monady zachowywały się tak samo jak pojedyncza monada.
Rozwiązanie
Zanim przejdę do wspomnianych w tytule 38 linijek kodu, chciałbym pokazać, jak wygląda metodasaveUser
po ich implementacji:
def saveUser(name: String, age: Int, country: String): Future[Either[ValidationError, User]] = { val validationResult = for { _ <- validateIfUserDoesNotExist(name) _ <- validateName(name) _ <- validateAge(age, country) } yield () validationResult.onSuccess(repository.putUser(User(name, age, country))) } private def validateIfUserDoesNotExist(name: String): ValidationResult[ValidationError, Unit] = { val userDoesNotExist = repository.findUser(name).map(_.isEmpty) ValidationResult.ensureF(userDoesNotExist, onFailure = UserAlreadyExists(name)) } private def validateName(name: String): ValidationResult[ValidationError, Unit] = ValidationResult.ensure(name.length > 2, onFailure = InvalidName(name)) private def validateAge(age: Int, country: String): ValidationResult[ValidationError, Unit] = { val isAgeValid = ageService.isAgeValid(age, country) ValidationResult.ensureF(isAgeValid, onFailure = InvalidAge(age, country)) }
Dzięki zastosowanemu rozwiązaniu:
- Mamy jasne rozdzielenie odpowiedzialności pomiędzy poszczególnymi krokami walidacji.
- Walidacja jest przeprowadzana jako sekwencja kroków (łatwo analizować kod).
- Każdy krok walidacji znajduje się w osobnej funkcji (zasada jednej odpowiedzialności).
Metody pomocnicze zwracają typ ValidationResult
, który zawiera zagnieżdżone monady:Either
iFuture
. Sam typValidationResult
również zachowuje się jak monada, dlatego w celu stworzenia finalnego rezultatu walidacji, możemy go wykorzystać bezpośrednio w blokufor
. Tego rodzaju typ, który reprezentuje zagnieżdżenie dwóch typów monadycznych, to tzw. monad transformer.
Na obiekcie typuValidationResult
można wywołać metodęonSuccess
, która odpowiada za przeprowadzenie pożądanej operacji, jeśli walidacja się powiodła. W tym wypadku jest to zapis użytkownika.
38 linijek kodu w Scali
To trochę dużo jak na listing, ale zależało mi, żeby wszystko pokazać w jednym miejscu. Poniżej znajduje się całość zaproponowanej przeze mnie mikro-biblioteki, ułatwiającej walidację danych w Scali. Wykorzystuje onaEitherT
, monad transformer z biblioteki Cats i nie jest związana z konkretnym typem monady (w przykładzie został użytyFuture
, ale wykorzystywałem teżDBIOAction
pochodzące z biblioteki Slick).
Zachęcam do użycia tego kodu we własnych projektach i zaadoptowania go do własnych potrzeb.
package com.softwaremill.monadvalidation.lib import cats.Monad import cats.data.EitherT import scala.language.higherKinds trait ValidationResultLib[M[_]] { type ValidationResult[F, S] = EitherT[M, F, S] object ValidationResult { def successful[F, S](s: S)(implicit m: Monad[M]): ValidationResult[F, S] = EitherT.rightT(s) def failed[F, S](f: F)(implicit m: Monad[M]): ValidationResult[F, S] = EitherT.leftT(f) def ensure[F](c: => Boolean, onFailure: => F)(implicit m: Monad[M]): ValidationResult[F, Unit] = EitherT.cond[M](c, Unit, onFailure) def ensureM[F](c: => M[Boolean], onFailure: => F)(implicit m: Monad[M]): ValidationResult[F, Unit] = EitherT.right(c).ensure(onFailure)(b => b).map(_ => Unit) def fromOptionM[F, S](opt: M[Option[S]], ifNone: => F)(implicit m: Monad[M]): ValidationResult[F, S] = EitherT.fromOptionF(opt, ifNone) } implicit class ValidationResultOps[F, S](vr: ValidationResult[F, S]) { def onSuccess[S2](s2: => M[S2])(implicit m: Monad[M]): M[Either[F, S2]] = vr.onSuccess(_ => s2) def onSuccess[S2](fn: S => M[S2])(implicit m: Monad[M]): M[Either[F, S2]] = vr.semiflatMap(fn).value } }
Taki kod stosowałem całkiem intensywnie w ostatnim projekcie, składającym się z ponad 60 tys. linijek kodu scalowego. Różniły się trochę sygnatury, mieliśmy kilka dodatkowych funkcji, jednak tak naprawdę sposób działania był taki sam. Po prostu kod powyżej został okrojony dla potrzeb przykładowej metody saveUser.
W przypadku wykorzystania zaproponowanych 38 linijek kodu, prędzej czy później pojawi się konieczność dostosowania ich do konkretnych potrzeb projektu. Dlatego nie warto publikować ich jako osobnej biblioteki.
Aby wykorzystać ValidationResultLib
, wystarczy stworzyć obiekt go rozszerzający (np. o nazwie Validation
), który dostarczy implementację domniemanego parametru typu Monad[M]
, dla pożądanego typu monady. Następnie należy zaimportowaćValidation._
i odpowiednie funkcjonalności staną się dostępne.
Na potrzeby przykładu, przygotowałem obiekt FuturesValidation
:
object FuturesValidation extends ValidationResultLib[Future] { import cats.Monad import cats.instances.future.catsStdInstancesForFuture implicit def monad(implicit ec: ExecutionContext): Monad[Future] = catsStdInstancesForFuture }
Jak widać w przykładzie, typ Monad
pochodzi z biblioteki Cats. Co więcej, Cats dostarcza także domyślną instancję Monad[Future]
, dlatego implementacje tego przypadku jest bardzo prosta.
W projekcie, o którym wspominałem, walidacja przeprowadzana była na monadach pochodzących ze Slicka:
package object validation extends ValidationResultLib[DBRead] { implicit def monad(implicit ec: ExecutionContext): Monad[DBRead] = new Monad[DBRead] { override def pure[A](x: A): DBRead[A] = DBIOAction.successful(x) override def flatMap[A, B](fa: DBRead[A])(f: A => DBRead[B]): DBRead[B] = fa.flatMap(f) override def tailRecM[A, B](a: A)(f: A => DBRead[Either[A, B]]): DBRead[B] = f(a).flatMap { case Right(b) => pure(b) case Left(a1) => tailRecM(a1)(f) } } }
W tym wypadku DBRead
to alias na slickowe DBIOAction[_
, NoStream
,Read]
. Dzięki temu same typy gwarantują, że walidacja operuje jedynie na odczytach z bazy danych.
W tym wypadku również konieczne jest zaimplementowanie własnego domniemanego parametru na Monad[M, ponieważ użyty został inny niż obsługiwany przez Cats typ monady. Jest on konieczny, by przeprowadzać operacja na monadach ValidationResult
iEitherT
. Tak zresztą zbudowana jest biblioteka Cats — wymaga domniemanych parametrów, gwarantujących, że określone operacje są możliwe do przeprowadzenia na danym typie.Pracując ze skomplikowaną domeną biznesową często koniecznością staje się przeprowadzenie złożonego procesu walidacji danych. W tym artykule pokażę, jak rozwiązać problem z walidacją danych na zagnieżdżonych monadach, kiedy ilość kodu nadmiernie rośnie i trudno wyizolować odpowiednie poziomy abstrakcji. Proponowane rozwiązanie – 38 linijek kodu, które równie dobrze mogą być mikro-biblioteką, jest jednocześnie dobrym przykładem działania różnych wzorców programowania funkcyjnego: monad transformers, type classes i tagless final.
Spis treści
EitherT
ValidationResult
jest głównie aliasem na monad transformer EitherT
. Są jednak dwie główne zalety, dlaczego lepiej korzystać z tego aliasu, a nie z samegoEitherT
:
- Język jest bardziej konkretny — sama nazwa typu informuje o tym, że służy do obsługi walidacji.
- Ukryty jest typ zagnieżdżonej monady (np. Future).
EitherT
przyjmuje trzy parametry typów: monadę, lewą stronę i prawą stronę. Typ monady dlaValidationResult
jest podany w obiekcie rozszerzającymValidationResultLib
i nie trzeba go nigdzie podawać ponownie.
Biblioteka Cats dostarcza wiele użytecznych funkcji dlaEitherT
, obsługującychEither
zagnieżdżone w innej monadzie (czyli M[Either], np. Future[Either}
,EDBIO[Either]
itp.). Większość z nich wymaga domniemanego parametru typu Functor[M]
, Applicative[M]
, czy Monad[M]
, w zależności od tego, jakie operacje mają być przeprowadzane na M. W implementacji ValidationResultLib należy podać instancję monady, gdyż ona wyczerpuje możliwości także pozostałych dwóch typów.
RozszerzenieMonad[M]
wymaga implementacji trzech funkcji:pure
,flatMap
itailRecM
. Pierwsze dwie są wystarczające, by zdefiniować monadę. Z kolei tailRecM
jest wymagane przezCats
, co wynika z budowy biblioteki, a nie z samej definicji monady (opisano to w dokumentacji). Istnieją także inne zestawy funkcji wystarczających do zdefiniowania monady, ale nie chciałbym tu wchodzić w szczegóły. Zachęcam do zapoznania się z rozdziałem 11 z książki Functional Programming in Scala Paula Chiusano, albo np. artykułu dostępnego tutaj.
Warto dodać, że przekazywanie domniemanych parametrów, które gwarantują możliwość wykonania określonych operacji na danym obiekcie to znany wzorzec projektowy w programowaniu funkcyjnym — type classes.
Type classes
Type classes to sposób implementacji polimorfizmu ad-hoc — dodawanie zachowania różnym obiektom, które pasują do określonego interfejsu. Type classes składają się z trzech elementów (por. Functional Programming, Simplified, Alvina Alexandra):
- Odpowiedniej klasy, albo cechy (trait), przyjmującej przynajmniej jeden parametr generyczny, pełniącej funkcję type class.
- Instancji tej klasy (cechy), której zachowanie będzie rozszerzane.
- Metod dostępu do nowego API, wykorzystującego tę klasę.
Na poniższym listingu zaznaczone są te elementy dla opisywanego w ramach artykułu przykładu:
// 1. The type class (this is cats.Monad simplified) trait Monad[M] { def pure[A](x: A): M[A] def flatMap[A, B](fa: M[A])(f: A => M[B]): M[B] def tailRecM[A, B](a: A)(f: A => M[Either[A, B]]): M[B] } object FuturesValidation extends ValidationResultLib[Future] { implicit def monad(implicit ec: ExecutionContext): Monad[Future] = // 2. The type class instance new Monad[Future] { override def pure[A](x: A): Future[A] = Future.successful(x) override def flatMap[A, B](fa: Future[A])(f: A => Future[B]): Future[B] = fa.flatMap(f) ... } } import FuturesValidation._ // 3a. The API - function in an object (defined in ValidationResult object) val result = ValidationResult.successful(38) // 3b. The API - implicit function (defined in ValidationResultOps implicit class) result.onSuccess(i => s"$i is valid")
Type classes w znacznym stopniu wykorzystywane są w bibliotece Cats. Kiedy zdefiniowana jest instancja typuMonad[F]
, można wykonywać poprzez Cats określone operacje naF
, ponieważ dostępny jest określony interfejs, który pozwala traktowaćF
jak monadę.
Podobne podejście wykorzystane jest w ValidationResultLib
. W implementacji 38 linijek kodu nie jest istotne, jaki rodzaj typu opakowujeEither
. Istotne jest to, że można przeprowadzać na nim takie operacje, jak na monadzie.
Tego rodzaju sparametryzowanie typu monady jest jednocześnie reifikacją innego wzorca, znanego z programowania funkcyjnego: tagless final.
Tagless final
Głównym celem tagless final jest sparametryzowanie typu monady (jako typu generycznego klas i cech). Ten wzorzec również składa się z trzech elementów (por. źródła 1, 2 i 3):
- Początkowego zestawu instrukcji (znanego też jako algebra, język, albo DSL), który określa, jakie operacje można wykonać na parametryzowanym typie.
- Opisu rozwiązania, gdzie zdefiniowane wcześniej instrukcje wykorzystywane są w implementacji.
- Interpretera, który zawiera implementacje instrukcji dla określonego typu monady.
Klasyczne przykłady reifikacji wzorca tagless final najczęściej wykorzystują repozytoria i usługi. Jest na przykład zaimplementowana klasaUserRepository[M[_]]
i metoda Udef findUser(id: String): M[Option[User]]
. Typ monady nie jest znany zarówno dla samej klasy, jak i dla innych klas, które wywołuję metodę findUser.
W przypadku ValidationResultLib[M[_]]
sytuacja jest trochę inna, jednak parametrM[_]
sugeruje, że także i tutaj może być wykorzystany wzorzec tagless final. Najlepiej sprawdzić to na przykładzie:
// 1. The initial set of operations (this is cats.Monad simplified) trait Monad[M[_]] { def pure[A](x: A): M[A] def flatMap[A, B](fa: M[A])(f: A => M[B]): M[B] def tailRecM[A, B](a: A)(f: A => M[Either[A, B]]): M[B] } // 2. The description of the solution trait ValidationResultLib[M[_]] { type ValidationResult[F, S] = EitherT[M, F, S] object ValidationResult { def successful[F, S](s: S)(implicit m: Monad[M]): ValidationResult[F, S] = EitherT.rightT(s) ... } implicit class ValidationResultOps[F, S](vr: ValidationResult[F, S]) { def onSuccess[S2](s2: => M[S2])(implicit m: Monad[M]): M[Either[F, S2]] = vr.onSuccess(_ => s2) ... } } // 3. The interpreter object FuturesValidation extends ValidationResultLib[Future] { implicit def monad(implicit ec: ExecutionContext): Monad[Future] = new Monad[Future] { override def pure[A](x: A): Future[A] = Future.successful(x) override def flatMap[A, B](fa: Future[A])(f: A => Future[B]): Future[B] = fa.flatMap(f) ... } }
Wydaje się, że i w tym przypadku można mówić o tagless final. Początkowy zestaw operacji to funkcje: pure
,flatMap
i tailRecM
. Cały kod mikro-biblioteki, wewnątrz ValidationResultLib to opis rozwiązania. Wreszcie, obiekt FuturesValidation
jest interpreterem, gdzie początkowy zestaw operacji jest zaimplementowany dla typu Future
.
Są dwie główne zalety wykorzystania takiego podejścia w ValidationResultLib
. Wspominałem już o nich wcześniej, ale teraz można sformułować je w kontekście tagless final:
- Można skopiować 38 linijek kodu i zacząć ich używać po napisaniu interpretera dla określonego typu monady.
- Ponieważ typ monady zapisany jest na poziomie cechy (trait), nie ma konieczności parametryzowania metod typem monady (kiedy kompilator nie jest w stanie wywnioskować tego automatycznie).
Wykorzystanie tagless final ma swoje zalety, głównie dzięki lepszej izolacji abstrakcji. Korzystanie jednak z niego przekrojowo, w całej aplikacji, może być moim zdaniem problematyczne. W proponowanym przykładzie wzorzec ten jest wykorzystany wewnątrz kodu mikro-biblioteki, a nie na poziomie repozytoriów i usług.
Podsumowanie i wnioski
W tym artykule pokazałem 38 linii kodu ułatwiających walidację danych w Scali. Takie podejście bardzo mi pomogło w poprzednim projekcie, eliminując sporo niepotrzebnego, zagmatwanego kodu. Ma ono niewątpliwe zalety, jednak może być problematyczne.
Po pierwsze, nie zawsze walidacja oparta na Either
to najlepsze rozwiązanie. W tym wypadku, jeśli pierwsza reguła walidacji zwróci błąd, proces walidacji się kończy. Zwracana jest jedynie informacja o pierwszym napotkanym błędzie. Jest to użyteczne, jeśli chcemy na przykład uniknąć niepotrzebnych zapytań do bazy danych, albo zewnętrznych usług. Są jednak sytuacje, kiedy pożądana jest informacja o wszystkich błędach walidacji, a nie tylko o pierwszym. Wówczas, zamiastEither
, użyteczne może być wykorzystanie cechyValidated
z biblioteki Cats i rozbudowanie walidacji na podstawie tego typu.
Pojawia się także problem wynikający z wykorzystania ExecutionContext
w Scali. Domniemane parametry tego typu często są konieczne przy wykonywaniu wielu operacji na monadach (i podobnych typach), takich jak Future
(więcej np. w tej dyskusji na Reddit). Dlatego w prezentowanych przykładach parametr typu Monad[Future]
był tworzony w locie: Eimplicit def monad(implicit ec: ExecutionContext)
. Nie mamy dostępnej jednej instancji tego parametru, tylko za każdym razem tworzona jest nowa, w zależności od danego ExecutionContext. Narzut jest pewnie niewielki i akceptowalny, jednak mogą wystąpić takie przypadki użycia, gdy doprowadzi do problemów z wydajnością.
Oprócz tego pojawia się szerszy problem związany z wydajnością, który może czasami wystąpić przy wykorzystaniu monad transformers. To dlatego, że tak naprawdę wirtualna maszyna Javy nie jest dostosowana do niektórych konstrukcji programowania funkcyjnego. Można o tym poczytać np. w tym artykule Johna A De Goesa.
Innymi słowy, mamy do czynienia z pewnym kompromisem. Znacząca poprawa czytelności kodu, ale związana z niewielkimi problemami z wydajnością, które mogą się pojawić w pewnych okolicznościach. W większości przypadków wybór jest prosty.
Wszystkie przykłady zawarte w artykule można znaleźć w tym repozytorium na GitHubie.
Artykuł został pierwotnie opublikowany na blog.softwaremill.com. Zdjęcie główne artykułu pochodzi z unsplash.com.