В 2021 году Мартин Одерски, создатель Scala, выпустил новую версию — Scala 3. С тех пор экосистема адаптируется, а интерес к ней растет: по статистике JetBrains, Scala 3 стала основным выбором для новых проектов.

В то же время развитие Scala 2 постепенно сворачивается — поддержка осуществляется компанией Akka.

Scala center рассчитывает привлечь новых разработчиков и проекты за счет ускоренного внедрения инноваций в язык.

В этой статье — наш опыт миграции трехлетней кодовой базы с Scala 2.13.15 на Scala 3: рассказываем, как справлялись с типовыми ограничениями и совместимостью, переписывали макросы и адаптировали популярные библиотеки, и почему архитектура модульного монолита помогла нам пройти этот путь безболезненно.

Александр Павлычев

Старший инженер-программист в Naumen Enterprise Search

Почему переход на Scala 3 актуален именно сейчас

  • LTS  — длительная поддержка и бинарные совместимости версий

  • более мощная система типов: enum, opaque, type lambda, intersection/union

  • появление новых языковых фич: контекстные ф-ии, inline-методы, extension-методы, type class derivation

  • метапрограммирование — новая модель макросов

  • поддержка виртуальных потоков Java 21 (через проекты Ox и Caprise)

  • интеграция новых библиотек Scala 3 Only

  • безопасность и удобство

Поэтому мы, получив поддержку от технического директора Алексея Воронца и нашего комьюнити scala_any, завели в проекте git-ветку Scala 3 и начали движение по минному полю.

Исходная система — платформа с самописным движком краулеров, конвейером и модулем полнотекстового и векторного поиска. За три года накопилась внушительная кодовая база на свежем ФП‑стеке: catsEffect3, fs2, zio, tapir, tofu, http4s, chimney, quill, doobie, elastic4s, circe, monocle, refined, otel4s, pureconfig. Все это работало на последней версии Scala 2.13.15.

Архитектура относится к модульному монолиту (монорепа), который условно можно разбить на 3 типа модулей:

  • инфраструктурные — «обертки» вокруг postgres, kafka, s3, web‑server и другие

  • бизнесовые

  • модули уровня приложений — слабо‑связанные деплой юниты

План миграции у нас был следующий:

  1. Нормализация графа зависимостей проекта для Scala 3

  2. Переписывание макроса

  3. Компиляция всех инфраструктурных модулей

  4. Компиляция бизнесовых модулей

  5. Компиляция модулей уровня приложений

  6. Прохождение unit-тестов

  7. Проверка локального запуска

  8. Настройка компилятора и тулинг

Нормализация графа зависимостей проекта для Scala 3

Основная задача — добиться отсутствия бинарно‑несовместимых зависимостей в проекте на sbt для Scala 3. Для этого мы обратились к гайду миграции от Scala Center и установили sbt‑плагин sbt‑scala3-migrate.

Нам стали доступны следующие команды:

  • migrateDependencies — составляет список зависимостей для обновления до Scala 3

  • migrateScalacOptions — аналогично портирует scalacOptions

  • migrateSyntax — указывает несовместимый синтаксис между Scala 2.13 and Scala 3

  • migrateTypes — пробует компилировать код под Scala 3

Благодаря командам migrateDependencies и migrateScalacOptions мы обновили зависимости и выявили проблемные места. 

Либы с кросcкомпиляцией Scala 3 и Scala 2 

Оказалось, что большая часть либ уже поддерживала Scala 3 — typelevel, zio, http4s, tofu, pureconfig, tapir, chimney и другие, поэтому мы сразу же их обновили.

Плагин better-monadic-for теперь не нужен

Появилась нативная поддержка внутри Scala 3. В for‑comprehension синтаксис изменился:

Scala 2
implicit0(ps: PermissionService[F, OIDCUser, NKCPermission])         <-  KeycloakPermissionService[F].local[Configuration](_.oidc)
implicit0(auth: AuthService[F, OIDCUser, NKCPermission, OIDCContext]) =  new AuthService[F, OIDCUser, NKCPermission, OIDCContext](oidc)
Scala 3
given PermissionService[F, OIDCUser, NKCPermission]              <- KeycloakPermissionService[F].local[Configuration](_.oidc)
auth @ given AuthService[F, OIDCUser, NKCPermission, OIDCContext] =  new AuthService[F, OIDCUser, NKCPermission, OIDCContext](oidc)

Плагин kind-projector заменяется опцией компилятора -Ykind-projector

Либы без поддержки Scala 3

Для вывода кодеков сложных ADT-типов с дискриминатором применяли либу circe-generic-extras. Уже много лет висит PR для Scala 3. Олег Нижников подсказал следующее решение:

Scala 2
import io.circe.generic.extras.Configuration
import io.circe.generic.extras.semiauto.deriveConfiguredCodec
 
sealed trait DocumentPrincipalAcl
object DocumentPrincipalAcl {
  case object IS_PUBLIC extends DocumentPrincipalAcl
  case class ACL(read: List[PrincipalId], denied: List[PrincipalId]) extends DocumentPrincipalAcl
  
  implicit val config: Configuration = Configuration.default.withDefaults.withDiscriminator("type")
  implicit val codec: Codec[DocumentPrincipalAcl] = deriveConfiguredCodec[DocumentPrincipalAcl]
}
Scala 3
import io.circe.derivation.{Configuration, ConfiguredCodec}
 
enum trait DocumentPrincipalAcl derives ConfiguredCodec
{
  case IS_PUBLIC
  case ACL(read: List[PrincipalId], denied: List[PrincipalId])
 
}
 
object DocumentPrincipalAcl {
  given Configuration = Configuration.default.withDefaults.withDiscriminator("type")
}

Для работы с jwt использовали либу scala‑nimbus‑jose‑jwt — в результате законтрибутили поддержку Scala 3 самостоятельно. Плагин sbt‑scala3-migrate указывал на возможность запустить обе либы в режиме CrossVersion.for3Use2_13, но по причине конфликта транзитивных зависимостей это оказалось невозможно.

Либы с макросами

Для работы с postgres слой репозиториев был выполнен с помощью либы zio‑quill, которая построена на макросах Scala 2. В данном случае выбора не было — пришлось полностью отказываться от либы и использовать ее порт в проекте zio‑protoquill (Scala 3 only).

Либы завершающие поддержку Scala 2 с переходом на Scala 3 only

Также заметили, что некоторые либы из typelevel завершают поддержку Scala 2. Например, либа cats‑retry, начиная с мажорной версии 4.0.0, строится на Scala 3 only.

Переписывание макроса

При внедрении кластера postgres для уменьшения трудоемкости по переписыванию 100 500 репозиториев использовался макрос на Scala 2, который анализировал AST‑дерево и подменял метод.transact (используем doobie‑quill) на методы.transactWithMaster, либо на.transactWithReplica, в зависимости от встречающихся sql‑операций в этом методе.

При этом сам макрос применялся как макро‑аннотация на весь класс репозитория, чтобы была возможность просмотра и манипуляции всем контекстом вызова метода.

Попытка приземлить данное решение в «лоб» на Scala 3 оказалась неуспешной: макро‑аннотации являются экспериментальными и содержат багу.

Решением стало полностью отказаться от макроаннотаций и переписать макрос через inline‑методы, которые успешно завелись благодаря тому, что в Scala 3 мы можем выходить за скоуп самого макроса и анализировать AST‑дерево вызовов снаружи.

import quotes.reflect.*
var sym = Symbol.spliceOwner // начинаем с того места, где сработал макрос
// Перемещаемся по родителям вверх до тех пор, пока не находим верхнеуровневый (классовый) val или def:
while sym != null && !((sym.isDefDef || sym.isValDef) && sym.owner.isClassDef) do sym = sym.owner
 
sym.tree

Scala 2 -> Scala 3 — неудачный порт
Scala 3 — исправление через inline-метод

Компиляция всех инфраструктурных модулей

Инфраструктурные модули не зависели от либ Scala 3 only, поэтому мы попробовали завести код в режиме кросскомпиляции со Scala 2, чтобы сделать переход на Scala 3 более итеративным.

Обратились к плагину sbt‑scala3-migrate и командам migrateSyntax и migrateTypes: первая команда позволила «причесать» кодовую базу, но со второй возникли проблемы с мультимодульными приложениями — нормально не работает. Поэтому «ручками» запускали sbt +compile. Из интересного поймали следующую ошибку компиляции:

This TASTy file was produced by a more recent, forwards incompatible release.
To read this TASTy file, please upgrade your tooling.
The TASTy file was produced by Scala 3.4.1-bin-nonbootstrapped.
error while loading packageSpackage,

Мы использовали 3.3.5 LTS, но какая‑то зависимость «затянула» Scala Next версии 3.4.1. В первую очередь нужно было локализовать проблему до конкретной либы: искали сбоящий модуль в ошибке компиляции, затем определяли, от каких специфичных библиотек он зависит.

Далее методом исключения комментировали в build.sbt зависимые либы одну за другой, пока не определилась проблемная — в первую очередь возникает TASTy ошибка, потом уже все остальные, даже если не хватает закоменченных либ. В нашем случае помогло простое увеличение версии либы до последней. В итоге удалось скомпилировать инфраструктурные модули.

Компиляция бизнесовых модулей

Наступило время переводить проект на Scala 3 only, нас ждали бизнесовые модули с моделями и репозитории на quill.

Базовый синтаксис

В Scala 3 изменились некоторые конструкции, что потребовало рутинной работы по их замене.

Синтаксис imports:

Scala 2
import cats.implicits._
import org.typelevel.otel4s.trace.{SpanKind, StatusCode => Status, Tracer}
Scala 3
import cats.implicits.*
import org.typelevel.otel4s.trace.{SpanKind, StatusCode as Status, Tracer}

Синтаксис compound Types (T with U) заменяется на Intersection Types (T & U) и Union Types (T | U):

Scala 2
def contentEndpoint[F[_]]: PublicEndpoint[(String, String), Unit, fs2.Stream[F, Byte], Any withFs2Streams[F]] = ???
Scala 3
def contentEndpoint[F[_]]: PublicEndpoint[(String, String), Unit, fs2.Stream[F, Byte], Any & Fs2Streams[F]] = ???

Макро-аннотации заменяли на derives:

Scala 2
import tofu.logging.derivation.loggable
 
@derive(loggable)
sealed trait LemmatizerType extends EnumEntry
Scala 3
import tofu.logging.Loggable
import tofu.logging.derivation.derived
sealed trait LemmatizerType extends EnumEntry derives Loggable
Scala 2
import io.circe.generic.JsonCodec
 
@JsonCodec case class OrderByIntersectionsDTO(compareWithDocuments: Seq[DocumentId])
Scala 3
import io.circe.Codec
 
case class OrderByIntersectionsDTO(
    compareWithDocuments: Seq[DocumentId]
) derives Codec

Изменения в vararg splices:

Scala 2
JsonObject(Seq(...).flatten: _*)
Scala 3
JsonObject(Seq(...).flatten*)  

Адаптация tofu

В качестве основной библиотеки логирования в формате ELK используется tofu‑logging.

Компилятор Scala 3 выдавал плавающую ошибку на разных сервисах при выводе логера — например, для типа JobExecutor.Log:

class JobExecutor[F[_]: Sync: JobExecutor.Log]
object JobExecutor extends LoggingCompanion[JobExecutor] {
  val metrics = ???
  
  def apply[F[_]: Async](implicit logs: Logs[F, F]): Kleisli[Resource[F, *], JobExecutorConfig, JobExecutor[F]] = ???
}
 
[error] 15 |class JobExecutor[F[_]: Sync: JobExecutor.Log](semaphore: Semaphore[F]) {
[error]    |                              ^^^^^^^^^^^^^^^
[error]    |  type Log is not a member of object ru.naumen.ism.nkc.jobs.JobExecutor.
[error]    |  Extension methods were tried, but the search failed with:
[error]    |
[error]    |      Cyclic reference involving implicit parameter evidence$2
[error]    |
[error]    |       Run with -explain-cyclic for more details.

Ребята из tofu‑комьюнити оперативно подсказали, в чем может быть дело. Если в объекте компаньен JobExecutor есть инстанцирование «лишних» переменных metrics, то компилятор Scala 3 может вести себя некорректно.

Поэтому мы некоторые объекты отрефакторили, в других использовали следующий workaround — заменили JobExecutor.Log на явный тип ServiceLogging:

import tofu.higherKind.HKAny
import tofu.logging.{Logs, ServiceLogging}
 
class JobExecutor[F[_]: Sync](semaphore: Semaphore[F])(using logging: ServiceLogging[F, JobExecutor[HKAny]]) 

Также стоит отметить, что Scala 3 более строгая по отношению к выводу типов с одной стороны и имеет проблемы с алиасами типов с другой — добавили более строгий RLogs и метод biwiden не компилируется с тайп‑алиасами:

Scala 2
  private type Run[+T] = RIO[WSContext, T]
  
  def server()(implicit logs: Logs[Run, Run]): Resource[Run, (CONFIG_FILE, Server)] = ???
  
  def mainProgram(logs: ULogs, serviceLogs: Logging[UIO]): ZIO[WSContext, Throwable, Nothing] =
    server()(logs.biwiden[Run, Run])
      .use { case (configFile, _) =>
        serviceLogs.info(s"Server started with config: $configFile") *> ZIO.never
      }
Scala 3
 type Run[+T] = RIO[WSContext, T]
  type RLogs   = Logs[Run, Run]
  
  def server()(implicit logs: Logs[Run, Run]): Resource[Run, (CONFIG_FILE, Server)] = ???
  
  def mainProgram(logs: RLogs, serviceLogs: Logging[Run]): ZIO[WSContext, Throwable, Nothing] =
    server()(logs.biwiden[RIO[WSContext, *], RIO[WSContext, *]])
      .use { case (configFile, _) =>
        serviceLogs.info(s"Server started with config: $configFile") *> ZIO.never
      }

Репозитории на quill

В рамках данной активности предстояло переписать доменные репозитории модулей с zio‑quill на zio‑protoquill.

Автор библиотеки проделал большую работу и ему удалось сохранить единую семантику методов для sql‑запросов с некоторыми естественными изменениями.

Как результат — мы сохранили бизнес‑логику запросов без серьезного рефакторинга. Но поймали следующие проблемы:

Метод filterOpt:

Scala 2
sortedItems.dynamic.filterOpt(archived)((is, a) => is.archived == a))
Scala 3
sortedItems.dynamic.filterOpt(archived)((is, a) => quote(is.archived == unquote(a)))

Расширения quill для Instant:

Scala 2
implicit class DateQuotes(left: Instant) {
    def range(start: Instant, end: Instant) = quote(sql"$left >= $start AND $left < $end".as[Boolean])
    def >(right: Instant)                   = quote(sql"$left > $right".as[Boolean])
    def >=(right: Instant)                  = quote(sql"$left >= $right".as[Boolean])
 
    def <(right: Instant)  = quote(sql"$left < $right".as[Boolean])
    def <=(right: Instant) = quote(sql"$left <= $right".as[Boolean])
}
Scala 3
import io.getquill.extras.InstantOps

Основные опасения с либой quill были связаны с компиляцией batch-запросов для массовой вставки или обновления объектов, но в нашем случае они не подтвердились — результирующие sql-запросы оказались корректными на десятках репозиториев.

Как бонус стоит отметить возможность видеть sql-запросы в качестве подсказки:

Компиляция модулей уровня приложений

В рамках данных модулей заменили implicit0 на given — теперь компиляция начала проходить.

Прохождение unit-тестов

Мы отправили тесты в CI, после началось волнующее ожидание результатов — итог был неутешительным, тесты намертво зависли.

Tapir

Проблема возникала в рекурсивной модели NkcSchema при выводе Tapir‑схемы — судя по дебагу компилятор заходил в код макроса и гулял по наследникам ADT по кругу.

Как оказалось, ситуация распространенная и легко исправляется.

Scala 2
implicit val schema: Schema[NkcSchema] = Schema.derived
Scala 3
implicit def schema: Schema[NkcSchema] = Schema.derived

Теперь тесты прошли успешно.

Проверка локального запуска

После мы начали локальный запуск. Запустили web‑сервис, но он завис намертво. Снова выстрелили рекурсивные схемы в Tapir. Основная сложность здесь была найти все подобные схемы по кодовой базе — благо их оказалось не так много. И наконец все заработало.

Регресс тестерами и нагрузочные тесты не выявили серьезных проблем, что подтверждает надежность Scala и ФП подхода в целом для сопровождения кодовой базы — с учетом хорошего покрытия тестами.

Настройка компилятора и тулинг

Когда эйфория прошла, мы выявили ряд серьезных проблем с производительностью компилятора и idea intellij в нашем проекте.

Подсветка ошибок Scala 3 в idea intellij

В idea есть два режима подсветки ошибок Settings → Languages & Frameworks → Scala → Editor → Error highlighting:

Buit‑in — встроенный и быстрый режим, но могут быть проблемы с точностью вывода на сложном коде
Compiler — тяжелый с компиляцией «на лету», работает медленнее, но точнее

На большой кодовой базе режим Compiler работает слишком медленно, поэтому пришлось переключиться на Built‑in, но появились некоторые проблемы с подсветкой — например, эта.

Медленная работа автодополнений методов у типов в idea intellij

В рамках проекта используется подход Tagless final с «прокидываем» тайп‑классов через bound‑context. При написании кода в момент формирования списка доступных методов у тайп‑классов Cats Effect наблюдались задержки в 7 секунд.

Опытным путем выяснили, что проблема возникала на файлах, в которых использовался «старый» способ подключения синтаксиса Cats Effect:

cats.implicits.*

После замены на рекомендуемый способ проблемы исчезли:

cats.syntax.all.given

Общая скорость компиляции проекта

Скорость компиляции проекта не претерпела сильных изменений. Для Scala 3 лучшим GC на java21 для idea по прежнему является ParallelGC, как и в Scala 2.

GC

Время

-XX:+UseParallelGC -XX:MaxInlineLevel=20

6 минут 20 сек

-XX:+UseG1GC -XX:MaxInlineLevel=20

7 минут 58 сек

-XX:+UseZGC -XX:MaxInlineLevel=20

9 минут 14 сек

Трассировки компилятора указывали на 2 проблемы с производительностью:

  • вывод кодеков Tapir и Circe через автоматическую деривацию ADT моделей

  • генерация sql-запросов через макросы quill

В рамках нашей архитектуры применяются слабо‑связанные модули, использующие общие модели БД, поэтому существует единый мега‑модуль описания моделей. Это создает «bottle neck» и другие модули должны ждать завершения его компиляции. Навешивание кодеков имеет конкретную цену. Также есть проблемы с большими таблицами (десятки колонок) при использовании quill — компиляция таких репозиториев может занимать минуты.

Что дальше?

Все заработало, но не совсем.

В будущем планируется провести несколько итераций рефакторинга для создания более идиоматичной кодовой базы с точки зрения Scala 3.

  1. Заменить implicit

    Scala 3 поддерживает «старый» синтаксис с implicit, что позволило нам не переписывать большую часть кода. Но более правильным является использование given/using для зависимостей, а также derives для тайп‑классов кодеков.

  2. Переключиться на enum

    В текущей кодовой базе для описания ADT используются sealed trait, которые имеет смысл заменить на enum для лаконичности. Также, наверное, стоит отказаться от либы enumeratum, которая имеет отличную поддержку в tapir, quill, pureconfig и даже Scala 3, но кажется не имеет особых преимуществ в Scala 3 Only проектах.

  3. Использовать extensions

    В Scala 2 для расширения существующих типов используются implicit class, в то время как в Scala 3 применяется современный и компактный синтаксис extensions.

Какие итоги мы получили

Переход на кодовую базу на Scala 3 оказался возможен в рамках нашего стека и проекта. Вначале мы собрали проект на самой последней версии 2.13.x и последних либах для лучшей совместимости.

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

Модульный монолит, типобезопасность Scala в сочетании с функциональным подходом является отличным архитектурным решением для стартапа, так как позволяет постепенно расти проекту, обеспечивая высокое удобство и безопасность разработки — рефакторинг всего проекта по мере роста бизнеса.

Отдельная благодарность
  1. Команде разработки Naumen Илье Иванову, Никите Кожевникову и Роме Ивашкину

  2. СНГ комьюнити за поддержку — особенно команде tofu и Олегу Нижникову

  3. Создателям и контрибутерам typelevel и zio за великолепную адаптацию к Scala 3

  4. Команде Akka за улучшение миграции со Scala 2 на Scala 3

  5. Мартину Одерски за новую эволюцию языка и интерес

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


  1. Dhwtj
    23.05.2025 19:48

    Приложение какую бизнес задачу решает?


    1. OhSirius
      23.05.2025 19:48

      Загрузка данных из источников и поиск по ним.


      1. Dhwtj
        23.05.2025 19:48

        ETL+BI?