kyo environment functions vs mtl-cats clean architecture
Article that references this page: Environment Functions
Core Link to heading
Let’s start with our domain/core. In both versions we have some set of abstractions being defined, and then a function, fetchAndSaveGame, that uses those abstractions. First let’s revisit these core abstractions, first for Clean Architecture:
case class GameGatewayFailure(error: String | Throwable)
case class GameRepositoryFailure(error: String | Throwable)
trait GameGateway:
def fetch(gameId: Long): EitherT[IO, GameGatewayFailure, Game]
trait GameRepository:
def save(game: Game): EitherT[IO, GameRepositoryFailure, Long]
type GameError = GameGatewayFailure | GameRepositoryFailure
def fetchAndSaveGame(gameId: Long, gateway: GameGateway, repo: GameRepository) =
for
game <- gateway.fetch(gameId).leftMap[GameError](identity)
_ <- repo.save(game).leftMap[GameError](identity)
yield game
Remember, we have to define general error types GameGatewayFailure and GameRepositoryFailure that specific implementations can map their specific error types to. Otherwise there’d be no way to get the implementations to compile, we wouldn’t be able to align the types unless we simply ignored/threw away the errors, or widened the error type, but then our callers wouldn’t have any idea what they could do with the error beyond print it.
As for kyo, our core abstractions are much smaller:
type FetchGame[S] = Long => Game < S
type SaveGame[S] = Game => Long < S
def fetchAndSaveGame[S](gameId: Long) =
for
game <- Env.use[FetchGame[S]](_(gameId))
_ <- Env.use[SaveGame[S]](_(game))
yield game
Not only does this code allow arbitrary error types to exist in the implementation without leaking into our domain, but it actually allows any arbitrary effects! Let’s say one of our implementations for FetchGame wanted the following capabilities:
- async
- erroring with a RequestFailure
- a counter tracking how many requests fire and how many fail
- emitting each status code (as an effect, not as a result)
Our domain doesn’t change at all in the kyo, those effects could be implemented (Async, Abort[RequestFailure], Var[Counter] and Emit[StatusCode] respectively), and our fetchAndSaveGame function would correctly use all of those effects. S would become the intersection of all of those effects (as well as all the effects of SaveGame).
Importantly, mtl-cats is also expressive enough to combine all these effects (with IO, Either[RequestFailure, *], State[Counter, *] and Writer[StatusCode, *] respectively), but sadly clean architecture requires that we leak these effect types into our abstraction, or the implementation simply wouldn’t be able to implement the abstraction.
This is how our GameGateway would have to change to allow these effects in the implementation:
trait GameGateway:
def fetch(gameId: Long):
EitherT[WriterT[StateT[IO, Counter, *], StatusCode, *], GameGatewayFailure, Game]
Our fetchAndSaveGame function actually comes out quite a bit mangled too:
type GameFailure = GatewayFailure | RepositoryFailure
def fetchAndSaveGame(gameId: Long, gateway: GameGateway, repo: GameRepository) =
for
game <- gateway.fetch(gameId).leftMap[GameFailure](identity)
saveEff = repo.save(game).leftMap[GameFailure](identity).value
_ <- EitherT.liftF(
WriterT.liftF[StateT[IO, Counter, *], List[StatusCode],
Either[AppError, Long]](
StateT.liftF[IO, Counter, Either[AppError, Long]](saveEff)))
yield game
At this point if you’re going to stay in the mtl-cats world, you have to start making some more drastic changes. Defining a hierarchy of type annotations, and creating natural transformations to make these sort of lifts easier. It’s also definitely gotten to the point where enough things are prepended with Game that it justifies its own namespace:
object Game:
case class GatewayFailure(error: String | Throwable)
case class RepositoryFailure(error: String | Throwable)
type GatewayStateT = StateT[IO, Counter, *]
type GatewayWriterT = WriterT[GatewayStateT, List[StatusCode], *]
type GatewayT = EitherT[GatewayWriterT, GatewayFailure, *]
trait Gateway:
def fetch(gameId: Long): GatewayT[Game]
trait Repository:
def save(game: Game): EitherT[IO, RepositoryFailure, Long]
val ioToGatewayWriterT = new (IO ~> GatewayWriterT):
def apply[A](fa: IO[A]) = WriterT.liftF(StateT.liftF(fa))
type Error = GatewayFailure | RepositoryFailure
def fetchAndSaveGame(gameId: Long, gateway: Gateway, repo: Repository) =
for
game <- gateway.fetch(gameId).leftMap[Error](identity)
_ <- repo.save(game).leftMap[Error](identity).mapK(ioToGatewayWriterT)
yield game
Meanwhile our kyo core is unchanged from the 8 lines earlier. It’s also important to note this leakage is a problem with how Clean Architecture (and many other DI patterns) mixes with any kind of tracked effects. Language level suspension/async/throws like you’d see in Kotlin/Swift/Rust would get leaked. Standard library level CompletableFuture/Task like you’d see in Java/C# would also get leaked.
Implementations Link to heading
class HttpGameGateway(client: Client[IO]) extends GameGateway:
override def fetch(gameId: Long) =
EitherT(
client.expect[Game](s"https://somegame.com/games/$gameId")
.map(Right(_))
.handleError(err => Left(GameGatewayFailure(err.getMessage)))
)
class PostgresGameRepository(
ctx: DoobieContext.Postgres[SnakeCase],
transactor: Transactor[IO]
) extends GameRepository:
import ctx.*
override def save(game: Game) =
EitherT(
run(query[Game].insertValue(lift(game)))
.attemptSql.transact[IO](transactor)
).leftMap(err => GameRepositoryFailure(err.getMessage))
[WIP] Link to heading
congrats, you earned a cookie for getting to a WIP section 🍪 (assuming your device can render this unicode), still fleshing this part out but wanted to release the article since I’ve been putting it together for like a week now.