Scala даёт типы, от которых не отмахнёшься, и функциональную краткость, но без каркаса веб-сервис на ней собирается долго. Пара Play Framework и Slick закрывает обе стороны: первый берёт HTTP, маршруты и шаблоны, второй превращает SQL в типизированные запросы, которые проверяет компилятор. Play живёт в третьей мажорной ветке, актуальный выпуск 3.0.12 вышел в сентябре 2026 года, а в основе лежит Apache Pekko, преемник легендарного Akka. Модуль интеграции play-slick в актуальной стабильной ветке 6.x связывает обе части и управляет пулом соединений, а сам Slick живёт в версии 3.6.1. Разберём связку от файла маршрутов до транзакций, с кодом на Scala 3 и объяснением каждого шага.

Play Framework собирает MVC приложение на Scala и Pekko

Play это классический MVC-каркас: запрос приходит на сервер, маршрутизатор находит обработчик, контроллер обращается к модели и возвращает результат. Отличие от старших товарищей по цеху в том, что всё асинхронно по умолчанию: обработчики возвращают Future, а под капотом потоками управляет Pekko, экраны не блокируют друг друга. Движок исполняется на Pekko HTTP или Netty, выбирается зависимостью.

Переход на Pekko не был капризом. Изменение лицензии Akka в 2022 году заставило экосистему искать открытый преемник, и сообщество перенесло код в Apache Pekko, а Play 3 переехал на него с первой же стабильной версии. Для прикладного кода смена почти неощутима: те же типы Future, те же абстракции, но основа теперь под эгидой Apache.

@Singleton
class CatalogController @Inject() (
    cc: ControllerComponents,
    repo: ProductRepo,
) extends AbstractController(cc) {

  def list: Action[AnyContent] = Action.async { implicit request =>
    repo.all().map(products => Ok(Json.toJson(products)))
  }

  def show(id: Long): Action[AnyContent] = Action.async {
    repo.find(id).map {
      case Some(p) => Ok(Json.toJson(p))
      case None    => NotFound
    }
  }
}

Внедрение зависимостей работает через Guice прямо из коробки: аннотация Inject собирает контроллер из репозитория, репозиторий из базы, и ручной работы с конструкторами нет. Аннотация Singleton помечает компоненты, которых достаточно по одному на приложение.

Повторяемые проверки оборачивают в компоновщики действий, и защищённый маршрут собирается как из кубиков:

def adminOnly(action: Action[AnyContent]): Action[AnyContent] =
  Action.async { request =>
    request.headers.get("X-Admin-Token") match {
      case Some(t) if t == adminToken => action(request)
      case _                          => Future.successful(Forbidden)
    }
  }

Обёртка принимает действие как аргумент и возвращает новое, а в файле маршрутов обычный вызов контроллера просто обрастает проверкой. Плюс к этому каркас шлёт запрос через цепочку фильтров, где в один слой ложатся сжатие ответов, заголовки безопасности и ограничение списка допустимых хостов.

Маршруты в файле routes и Twirl шаблоны держат разметку отдельно

Маршруты Play описываются декларативно в одном текстовом файле, и это одна из самых приятных сторон каркаса: карта API перед глазами целиком, без обхода кода.

GET     /api/v1/products         controllers.CatalogController.list
GET     /api/v1/products/:id     controllers.CatalogController.show(id: Long)
POST    /api/v1/products         controllers.CatalogController.create

Сегмент с двоеточием становится параметром и типизируется прямо в файле, ошибка в имени метода ловится при сборке. Для REST-стеков этого достаточно, а для страниц предназначен Twirl, компилируемый шаблонизатор, где разметка и выражения Scala соседствуют:

@(products: Seq[Product])

<table>
@for(p <- products) {
    <tr><td>@p.title</td><td>@p.price</td></tr>
}
</table>

Шаблон компилируется в класс, поэтому опечатка в поле продукта валит сборку, а не рендер в продакшене. Параметры шаблона типизированы, частичные шаблоны вкладываются друг в друга, и итоговая страница собирается из маленьких кирпичей.

Обратная маршрутизация закрывает концы: адрес страницы или API строится вызовом routes с параметрами, и переименование маршрута ловится компилятором, а не битой ссылкой у клиента. Статика раздаётся контроллером ресурсов с версионными адресами, и браузеры кэшируют файлы ровно до следующей сборки.

Формы и валидация описываются маппингами в одну строку

Разбор входа и проверка значений собраны в мини-язык маппингов. Один объект Form объявляет, какие поля ждать, как их переводить и что считать ошибкой:

val productForm = Form(
  mapping(
    "title" -> nonEmptyText(minLength = 2),
    "price" -> of[BigDecimal].verifying(_ > 0),
  )(ProductForm.apply)(ProductForm.unapply)
)

def create: Action[AnyContent] = Action.async { implicit request =>
  productForm.bindFromRequest().fold(
    formWithErrors => Future.successful(BadRequest(formWithErrors.errorsAsJson)),
    form           => repo.create(form).map(p => Created(Json.toJson(p))),
  )
}

Метод bindFromRequest вытягивает поля из JSON или формы, fold развилкивает исход: ошибки уезжают с кодом 400 и описанием по полям, успех в репозиторий. Такой стиль исключает разбор кейсов по рукам, и код проверки живёт в одном месте. Для форм, отправляемых из браузера, каркас подставляет проверочный жетон CSRF без ручной работы, а для JSON-интерфейсов защита включается точечно фильтром.

Slick отображает таблицы на типизированные запросы

Slick позиционирует себя как функционально-реляционный слой: таблица описывается классом, запрос собирается из методов, похожих на коллекции Scala, и почти вся ошибка ловится компилятором. Описание таблицы выглядит объёмнее привычного, зато_columns читаются один раз:

class Products(tag: Tag) extends Table[Product](tag, "products") {
  def id    = column[Long]("id", O.PrimaryKey, O.AutoInc)
  def title = column[String]("title")
  def price = column[BigDecimal]("price")

  def * = (id.?, title, price)
    .mapTo[Product]
}

object Products extends TableQuery(tag => new Products(tag)) {
  def byId(id: Rep[Long]) = this.filter(_.id === id)
}

Запросы над таблицей выглядят как работа со списком: filter, sortBy, take, и каждый шаг остаётся типизированным. Скомпилированное выражение превращается в SQL на этапе исполнения, значения уходят параметрами, и внедрение через строку невозможно по построению.

Связи собираются тем же синтаксисом for, и соединение двух таблиц читается как перебор пар:

val join = for {
  p <- Products
  c <- Categories if p.categoryId === c.id
} yield (p.title, c.name)

Агрегаты не требуют кляузы groupBy наизусть, группировка выражается тем же методом, а сумма и счёт собираются поверх среза.

Для отчётных запросов, где DSL становится многословнее самого SQL, предусмотрен интерполятор с параметрами:

def titlesAbove(min: BigDecimal): DBIO[Seq[(String, BigDecimal)]] =
  sql"""SELECT title, price FROM products
        WHERE price > $$min""".as[(String, BigDecimal)]

Значение уходит в запрос параметром, результат типизирован кортежем, и жертвовать безопасностью ради выразительности не нужно.

Действия DBIO компонуются и выполняются в одном run

Ключевая идея Slick в том, что запрос это описание действия, а не его исполнение. Действия складываются в цепочку for, и весь набор выполняется одним вызовом db.run. Транзакция задаётся обёрткой transactionally, и откатывается целиком при любой ошибке:

def placeOrder(order: Order, productId: Long, qty: Int)
    : Future[Order] = {
  val action = for {
    stock    <- Stocks.byId(productId).result.head
    _        <- require(stock.count >= qty, DBIO.failed(new IllegalStateException("не хватает")))
    orderId  <- Orders returning Orders.map(_.id) += order
    _        <- Stocks.byId(productId)
                   .map(_.count).update(stock.count - qty)
  } yield order.copy(id = Some(orderId))

  db.run(action.transactionally)
}

Здесь проверка остатка, вставка заказа и уменьшение склада идут одной транзакцией, и одновременные заказы не перехватят остаток друг у друга. Композиция for это читаемая запись цепочки, каждый шаг DBIO, а сбой в середине откатывает всё. Для независимых запросов этого не требуется, и тогда проще запустить два run, чем держать транзакцию лишнее время.

Пул соединений в интеграции play-slick настроен через конфигурационный файл: количество соединений, строка доступа и диалект задаются по имени базы, а HikariCP под капотом переживает всплески. Дефолты разумны, но потолок подбирают под лимиты сервера, чтобы пул не открыл сотню соединений туда, где база держит двадцать. Независимые действия складываются через DBIO.sequence, когда порядок не важен, и выполняются параллельно внутри одного run.

Интеграция play-slick и эволюции схемы под контролем

Модуль play-slick приносит в проект компонент Api и конфигурацию баз по именам. Ссылка на базу в конфигурации, инъекция в репозиторий, и код остаётся в неведении о строках подключения:

slick.dbs {
  default {
    profile = "slick.jdbc.PostgresProfile$"
    db {
      url       = "jdbc:postgresql://db/shop"
      user      = ${?DB_USER}
      password  = ${?DB_PASS}
      numThreads = 10
    }
  }
}

Схемой управляют эволюции, текстовые файлы нумерованных сценариев в каталоге conf/evolutions. Часть Ups применяется вперёд, часть Downs откатывает:

-- !Ups
CREATE TABLE products (
  id    BIGSERIAL PRIMARY KEY,
  title VARCHAR(200) NOT NULL,
  price NUMERIC(10,2) NOT NULL
);

-- !Downs
DROP TABLE products;

В разработке Play применяет сценарии сам и показывает страницу с подтверждением, в продакшене вопросами рулит конвейер. Конфликт разрешается порядком номеров: сценарий с новым номером едет только после предыдущих, а расхождение схемы с историей подсвечивается сразу. Эволюции просты до примитива, и в этом их сила: сценарий это SQL, он читается и проверяется глазами, а версионность хранится в таблице схемы.

Практическая сборка проекта от sbt до релиза

Проект собирается sbt, и запуск sbt run поднимает сервер разработки на порту 9000 с перезагрузкой кода на лету. Сборка пакета командой sbt dist даёт архив с бинарником и библиотеками, который распаковывается и запускается скриптом в bin, без установленой Scala на сервере.

Из коробки разработчик получает несколько удобств:

  1. сервер с перезагрузкой кода и перекомпиляцией за секунды;
  2. страница ошибок с фрагментом кода и подсветкой строки;
  3. фильтры для сжатия, безопасности и куки как готовые компоненты;
  4. конфигурация в HOCON с подстановкой переменных окружения;
  5. встроенный контроль доступа по политикам заголовков;
  6. интеграция с тестовыми фреймворками и тестами маршрутов.

Тесты контроллеров пишутся через тестовые приложения: запрос идёт в маршрут, ответ приходит как значение, а база подменяется исполнением Slick поверх H2 в памяти.

class CatalogControllerSpec extends AnyFreeSpec with Matchers {

  "список отдаёт товары" in {
    val app    = new GuiceApplicationBuilder().build()
    val repo   = app.injector.instanceOf[ProductRepo]
    val form   = ProductForm("Чайник", BigDecimal(900))

    Await.result(repo.create(form), 3.seconds)

    val Some(result) = route(app,
      FakeRequest(GET, "/api/v1/products"))

    status(result) mustBe OK
    contentAsJson(result).as[Seq[JsObject]].size mustBe 1
  }
}

База для теста собирается на H2 теми же таблицами, поэтому эволюции прогоняются и в тестах, ловя ошибки сценариев до конвейера. Сборка в конвейере гоняет тесты, потом dist, и артефакт едет в контейнер, где скрипт запуска ждёт своих переменных окружения. Порт и таймауты слушателя задаются в том же конфигурационном файле, что держит порядок кода единым от ноутбука до продакшена.

Прагматичный итог. Пара Play и Slick закрывает типичный бэкенд на Scala: маршруты и контроллеры короткие, SQL типизирован, транзакции компонуются, а миграции лежат в читаемых файлах. Опытные команды ценят связку за честность: код либо собирается, либо не работает, серой зоны с падениями в рантайме здесь мало. Тяжёлые джентльмены с полным зерном, вроде ZIO с его эффектами, берут верх в сложных распределённых системах, но для сервиса с CRUD-ом, парой отчётов и честной типизацией связка из третьей ветки Play с Slick даёт результат за дни, сохраняя чистоту кода на годы.