Привет Хабр! Пятничного тру ФП хардкора с Free Monad, Таглес Финал, Монад трансформерами, Refined Types, Smart Constructors и прочим таким вам в ленту. Хардкор сам себя в ленту не принесет так что погнали.

Гексагональная архитектура делит наш код на три основные части.

1) Primary Adapters,

2) Secondary Adapter

3) Logic aka Domain.

Primary Adapters изолируют наш домен от вызовов снаружи, фремворков и библиотек вызывающих наш код и эффектов вызванных ими. Например, ввод пользователя. Secondary Adapters изолирует нас и дает абстракции для работы из нашего кода с внешним миром, библиотеками и фреймворками которые вызывает наш код. Например, запись данных в файл. Так для DDD очень хорошо реализуется через Free Monad и Refined Types за счет написания DSL для домена и под доменов, а так же создания Value Objects через Refined Types. Далее мы будем реализовать Secondary Adapters через Free Monad и на созданном нами DSL уже писать код внутри Primary Adapters отдавая его наружу где он уже будет компилироваться.

Вся суть – в Domain описываем наш DSL на Free Monad и пишем на нем код. Primary Adapters отдают этот код наружу. Secondary Adapters содержит компиляторы для нашего кода.

Слои

Domain.Entities

Содержит сущности, присутствующие в нашем домене и являющиеся структурами данных. У сущностей может быть своя собственная логика. В ООП это делается с помощью методов сущностей. Тут мы будем делать это с помощью тайпклассов связанных с нашими сущностями. Например, Movable[A] и Movable[User] поместим в это слой.

Value Objects – объекты что сравниваются по всем их полям и являются типом для каких то базовых данных в домене. Это кирпичики, атомы из которых уже строиться все остальное. Например, UserName, HumanAge, Money, Inn (ИНН). Обертка над значением что дает ему свой собственный тип. Хорошо реализуются с помошью Refined Types

type UserId = String Refined NonEmpty
object UserId extends RefinedTypeOps[UserId, String] with CatsRefinedTypeOpsSyntax

type UserRole = NonEmptyFiniteString[255]
object UserRole extends RefinedTypeOps[UserRole, String] with CatsRefinedTypeOpsSyntax

type UserAge = Int Refined Interval.ClosedOpen[18, 150]
object UserAge extends RefinedTypeOps[UserAge, Int] with CatsRefinedTypeOpsSyntax

Entities – Сущности что сравниваются только по их уникальному идентификатору (id). Например, User, Order. Тут могут быть полезны проверки валидности типа с помошью паттерана Smart Constructors например чтобы проверить что какие-то два поля сушности равны друг другу если для сущностей данного типа предпологается что эти поля всегда должны быть равны или например для сущност представляющей собой тип для периода что его начало всега раньше его конца. Period.Start < Period.End.

case class User(id: UserId, age: UserAge, roles: List[UserRole])

Aggregate – набор сущностей собранных вместе для удобной работы с ними. Чтобы управлять ими как чем-то единым. Например, Order и OrderItem.  В ФП это работает через тайп классы для AggregateRoot (Если представить связь сущностей как граф в виде дерева, то это будет его корень). Order это AggragateRoot а OrderItem и OrderAddress это входящие в него объекты. Так, чтобы понять чуть подробней цель создания Aggregate давать посмотрим пример создания объекта Car для компьютерной игры с раздельными повреждениями. У нас есть корень Агрегата — это Машина. У нее есть части — это Двигатель, Колеса, Бензобак. У каждого из них есть свой запас прочности. Теперь нам надо написать код для уменьшения их прочности при столкновении. Мы можем достать из хранилища (БД той же) отдельно каждое колесо, уменьшит его прочности. Сохранить его в БД. Потом достать двигатель и уменьшить его поле HP и т.д. А можем сразу достать весь Aggregate и вызвать у него метод или вызвать метод typeclass на Aggregate в котором разом сразу уменьшится HP всех его частей. Потом разом одним запросом мы можем сохранить все его части в БД. getCar, saveCar, setHp(car, hp). Вместо getEngine, saveEngine, setHp(engine, hp). getWheel, saveWheel, setHp(wheel, hp) и т.д. Там же позицию в пространстве можно разом изменить одним методом для всей машины а не изменять координаты каждой из ее частей. Movable[A] и Movable[Car].

trait RolesOwner[A] {
  def isInRole(a: A, role: String): Boolean
}

object RolesOwner {
  def apply[A](implicit t: RolesOwner[A]): RolesOwner[A] = t
}

final class UserRolesOwner extends RolesOwner[User] {
  override def isInRole(a: User, role: String): Boolean = 
  	a.roles.exists(x => x.value.toLowerCase == role.toLowerCase)
}

implicit val userRolesOwner = new UserRolesOwner()

implicit class RolesOwnerOps[A](a: A)(implicit t: RolesOwner[A]) {
  def isInRole(role: String): Boolean = t.isInRole(a, role)
}

Domain.Services

Сервис — это объект, не являющийся структурой данных. Для ФП тут корректнее рассматривать сервис как набор функций. В общем тут скорее Domain. Functions более корректное название. Тут в ООП обычно лежат интерфейсы (абстракции ака trait или interface) репозиториев и других объектов для взаимодействия с внешним миром. Так же тут сервисы с логикой доменной. В ООП обычно все в методах самих Entities делается и тут только всякие Validators, Calculators лежат иногда. Тут же мы будем описывать наш DSL на Free. Компилятор же будет в слое SecondaryAdapters.

type m1[A] = EitherK[UsersRepositoryAlgebra, FilesRepositoryAlgebra, A]
type m2[A] = EitherK[UuidsRepositoryAlgebra, m1, A]
type MyApp[A] = EitherK[TokenParserAlgebra, m2, A]

sealed trait UuidsRepositoryAlgebra[A]

case class GetNext() extends UuidsRepositoryAlgebra[UUID]

class UuidsRepository[F[_]](implicit I: InjectK[UuidsRepositoryAlgebra, F]) {
  def getNext(): Free[F, UUID] = Free.liftInject[F](GetNext())
}

object UuidsRepository {
  implicit def uuidsRepository[F[_]](implicit I: InjectK[UuidsRepositoryAlgebra, F]): UuidsRepository[F] = new UuidsRepository[F]
}

PrimaryAdapters

Задача этого слоя изолировать нас и абстагироваться от того кто из внешнего мира который вызывает наш код. От контроллеров, хендлеров и прочего. От фреймворков внешних и библиотек вроде PlayFramework, Http4s.Это делает для того чтобы было легко перенести наш код с одного фреймворка на другой. Есди мы захотим чтобы наш код начал вызвать другой фреймворк. Например на Akka-Http. Тут мы создаем код программы на нашем DSL и отдаем его наружу. Скомпилирован он будет уже в самом приложении в Controllers или просто в Main если у нас консольное приложение. Например, AuthService содержит набор функций что возвращает программы для работы с авторизацией и аутентификацией пользователя. Проверка валидности пароля, выдача токена и прочее. Этот слой исполняет роль Anti Corruption Layer

final class FilesService(
    _users: UsersRepository[MyApp],
    _files: FilesRepository[MyApp],
    _uuids: UuidsRepository[MyApp],
    _tokenParser: TokenParser[MyApp]
) {

  type FM[A] = Free[MyApp, A]

  def upload[S[_]](token: AuthToken, file: UserFile[S]): EitherT[FM, String, UUID] =
    for {
      id   <- EitherT(_tokenParser.parse(token))
      user <- EitherT(_users.get(id))
      isAdmin = user.isInRole("admin")
      res <-
        if (!isAdmin) {
          EitherT.fromEither[FM]("You are not prepare!".asLeft[UUID])
        } else {
          saveFile[S](file)
        }
    } yield res

  def saveFile[S[_]](file: UserFile[S]): EitherT[FM, String, UUID] =
    for {
      uid <- EitherT.right[String](_uuids.getNext())
      _   <- EitherT.right[String](_files.add[S](file, uid))
    } yield uid
}

SecondaryAdapters

Задача этого слоя абстрагироваться и изолировать нас от внешнего мира и библиотек или фреймворков что мы вызываем чтобы легко было их заменить. Например библиотеку для работы с БД doobie на какую нибудь другую. Так же тут стартовая точка для "неопределенности", "хаоса", эффектов. Именно этот слой мокается в тестах или для него делают тестовые реализации через монаду Id. Тут обычно лежат реализации репозиториев в ООП. Абстракции над источниками данных. Чистый код – код без протекающих абстракций. Здесь реализации абстракций что работают с библиотеками и фреймворками для ввода вывода или возвращают рандомные значения. Для ФП тут мы будем компиляторы для DSL описанного в домене реализовать. Тут работы уже идет с IO, библиотеками для доступа к БД, Запросам по сети, Доступа к файлам конфигураций. Вообще все что создает эффекты и возвращает значения, не зависящие от входящих параметров функции в том числе случайные значения. Например, UUID.randomUUID() вызывать допустимо только в этом слое потому что этот метод возвращает разные значения при вызове с одним и тем же набором параметров (без параметров точнее). В общем посмотрите на функцию. Если она может вернуть разные значения при вызове с одним и тем же списком параметров значит ее место здесь. В этом слое изолируется неопределённость, хаос. Например чтение данных пользователя из БД, файла или из API какого-то сервиса может быть реализовано через функцию getUserById и если три раза вызвать этот метод getUserById(“1”) с перерывом в несколько дней то он может вернуть совершенно разные значения (пользователя удалили, пользователя перименовали) хотя мы передаем все тот же самый параметр id = “1”. Так же к этому относятся Instant.now() и Random.nextInt().Этот слой тоже исполняет роль Anti Corruption Layer. Тут же весь наш SQL код находиться.

type MyIo[A] = ConnectionIO[A]
def createInterpreter(implicit blocker: Blocker, contextShift: ContextShift[IO], sf: Sync[IO]):(MyApp ~> MyIo) = {
  val i1: m1 ~> MyIo             = new UsersRepositoryCompiler or new FilesRepositoryCompiler
  val i2: m2 ~> MyIo             = new UuidRepositoryCompiler or i1
  val interpreter: MyApp ~> MyIo = new TokenParserCompiler or i2
  interpreter
}

final class UuidRepositoryCompiler extends (UuidsRepositoryAlgebra ~> MyIo) {
  def apply[A](fa: UuidsRepositoryAlgebra[A]): MyIo[A] =
    fa match {
      case GetNext() => IO.delay(UUID.randomUUID().asInstanceOf[A]).to[ConnectionIO]
    }
}

//Тестовая реализация корая возвращает всегда одно и тоже значение.
//Так же можно сделать тестовую реализацию что возвращает значение преданное в конструктор
final class TestUuidRepositoryCompiler extends (UuidsRepositoryAlgebra ~> Id) {
  def apply[A](fa: UuidsRepositoryAlgebra[A]): Id[A] =
    fa match {
      case GetNext() => UUID.fromString("9ff050a3-3c97-4f85-9826-f7e5c630fa42").asInstanceOf[A]
    }
}

App

Наше приложение и специфичные для него вещи. Файлы миграций и конфигураций. Контроллеры и хендлеры. Точнее специфинчные для основной библиотеки нашего приложения от которой мы изолируемся. У меня сейчас это Tapir + Http4s поэтому тут будет весь код связанный с ними.

final class FilesController(_filesService: FilesService, _xa: Transactor[IO])(implicit blocker: Blocker, contextShift: ContextShift[IO], sf: Sync[IO]) {
  val uploadFile =
    FilesController.upload.serverLogic(x => uploadFn(x._1, x._2))

  def uploadFn(token: AuthToken, stream: fs2.Stream[IO, Byte]): IO[Either[String, String]] =
    _filesService
      .upload[IO](token, stream)
      .value
      //Тут проиходит компалия кода написанного на нашем DSL
      .foldMap(vw.ddd_scala.core.secondaryAdapters.createInterpreter)
     //Тут завершается формирование транзакции. Аналог Commit() для UnitOfWork
      .transact(_xa)
      .map(x => x.map(y => y.toString))
  
  val endpoints = List(
    uploadFile
  )
}

object FilesController {
  def upload =
    endpoint.post
      .in("api")
      .in("v1")
      .in("files")
      .tag("Files")
      .in("upload")
      .in(auth.bearer[AuthToken]())
      .summary("Загрузить файл картинки на сервер")
      .description("Загружает выбранную картинку")
      .in(streamBinaryBody(Fs2Streams[IO]))
      .errorOut(stringBody)
      .out(stringBody)
}

Тот же результата можно достичь без Free с обычными тайпклассами пример

//domain
 trait UuidRepositoryAlgebra[F[_], A] {
  def create(a: A): F[UUID]
}

object UuidRepositoryAlgebra {
  def apply[F[_], A](implicit algebra: UuidRepositoryAlgebra[F, A]): UuidRepositoryAlgebra[F, A] = algebra

  implicit class UuidRepositoryOps[F[_], A](a: A)(implicit algebra: UuidRepositoryAlgebra[F, A]) {
    def create() = algebra.create(a)
  }

}

//primary adapters
class UuidService[F[_], A](_uuidRepository: A)(implicit _uuidRepositoryAlgebra: UuidRepositoryAlgebra[F, A]) {

  import UuidRepositoryAlgebra._

  def create() = _uuidRepository.create()
}

//secondary adapters
final class UuidRepositoryInterpreter[F[_] : Sync, A] extends UuidRepositoryAlgebra[F, A] {
  def create(a: A): F[UUID] = Sync[F].delay(UUID.randomUUID())
}

//Usage
case class UuidRepository()

implicit val interpreter = new UuidRepositoryInterpreter[IO, UuidRepository]

val repo = new UuidRepository()
val service = new UuidService[IO, UuidRepository](repo)
service.create().map(x => println(x))) 

Исодники примера (основной код в папке core)

Комментарии (0)