В 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 и другие
бизнесовые
модули уровня приложений — слабо‑связанные деплой юниты
План миграции у нас был следующий:
Нормализация графа зависимостей проекта для 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.
-
Заменить implicit
Scala 3 поддерживает «старый» синтаксис с implicit, что позволило нам не переписывать большую часть кода. Но более правильным является использование given/using для зависимостей, а также derives для тайп‑классов кодеков.
-
Переключиться на enum
В текущей кодовой базе для описания ADT используются sealed trait, которые имеет смысл заменить на enum для лаконичности. Также, наверное, стоит отказаться от либы enumeratum, которая имеет отличную поддержку в tapir, quill, pureconfig и даже Scala 3, но кажется не имеет особых преимуществ в Scala 3 Only проектах.
-
Использовать extensions
В Scala 2 для расширения существующих типов используются implicit class, в то время как в Scala 3 применяется современный и компактный синтаксис extensions.
Какие итоги мы получили
Переход на кодовую базу на Scala 3 оказался возможен в рамках нашего стека и проекта. Вначале мы собрали проект на самой последней версии 2.13.x и последних либах для лучшей совместимости.
Благодаря модульности проекта была возможность постепенного обновления кодовой базы с контролем компиляции: начинали с инфраструктурных модулей, далее к бизнес модулям и заканчивали уровнем приложений и тестами. В рантайме не было выявлено серьезных проблем с основной функциональностью проекта, также не обнаружена деградация по нагрузочным тестам.
Модульный монолит, типобезопасность Scala в сочетании с функциональным подходом является отличным архитектурным решением для стартапа, так как позволяет постепенно расти проекту, обеспечивая высокое удобство и безопасность разработки — рефакторинг всего проекта по мере роста бизнеса.
Отдельная благодарность
Команде разработки Naumen Илье Иванову, Никите Кожевникову и Роме Ивашкину
СНГ комьюнити за поддержку — особенно команде tofu и Олегу Нижникову
Создателям и контрибутерам typelevel и zio за великолепную адаптацию к Scala 3
Команде Akka за улучшение миграции со Scala 2 на Scala 3
Мартину Одерски за новую эволюцию языка и интерес
Dhwtj
Приложение какую бизнес задачу решает?
OhSirius
Загрузка данных из источников и поиск по ним.
Dhwtj
ETL+BI?