Как реализовать dependency injection в Scala?

В Scala существует несколько способов реализовать dependency injection (DI), включая явное внедрение через параметры конструктора, использование self-type, cake pattern, implicit-параметры, макросы, DI-фреймворки (например, MacWire, Guice, Scaldi) и Tagless Final подход. Scala не имеет встроенного DI-контейнера, как Spring в Java, поэтому разработчики выбирают подход исходя из архитектуры проекта.

1. Внедрение зависимостей через параметры конструктора (Constructor Injection)

Самый простой и предпочтительный способ, особенно в функциональном стиле:

trait Database {
def query(sql: String): List\[String\]
}
class UserService(db: Database) {
def getUsers(): List\[String\] = db.query("SELECT \* FROM users")
}
class ProdDatabase extends Database {
def query(sql: String) = List("user1", "user2")
}
val service = new UserService(new ProdDatabase)

Преимущества:

  • Явные зависимости

  • Хорошо работает с тестированием

  • Простая реализация

2. Self-type и cake pattern

Использование self-type позволяет описать зависимости на уровне типов. Cake pattern строится на этом механизме.

trait DatabaseComponent {
trait Database {
def query(sql: String): List\[String\]
}
val database: Database
}
trait UserServiceComponent {
self: DatabaseComponent =>
class UserService {
def getUsers(): List\[String\] = database.query("SELECT \* FROM users")
}
val userService: UserService
}

Композиция:

object AppComponents extends UserServiceComponent with DatabaseComponent {
val database = new Database {
def query(sql: String) = List("userA", "userB")
}
val userService = new UserService
}

Cake pattern позволяет декомпозировать зависимости и строить модули, но может быть сложным в сопровождении.

3. Implicits (неявные зависимости)

Можно передавать зависимости неявно через implicit параметры:

trait Logger {
def log(msg: String): Unit
}
class ConsoleLogger extends Logger {
def log(msg: String): Unit = println(s"LOG: $msg")
}
class Service(implicit logger: Logger) {
def run(): Unit = logger.log("Service started")
}
implicit val logger = new ConsoleLogger
val service = new Service
service.run()

Плюсы:

  • Уменьшение шаблонного кода

  • Хорошо сочетается с DSL и функциональным стилем

Минусы:

  • Неочевидные зависимости

  • Сложнее отлаживать

4. Dependency Injection с помощью MacWire

MacWire — библиотека, генерирующая внедрение зависимостей во время компиляции, без рефлексии. Не требует аннотаций.

import com.softwaremill.macwire._
trait Database {
def query(sql: String): List\[String\]
}
class UserService(db: Database) {
def getUsers(): List\[String\] = db.query("SELECT \* FROM users")
}
class ProdDatabase extends Database {
def query(sql: String) = List("admin", "guest")
}
trait Components {
lazy val db = wire\[ProdDatabase\]
lazy val service = wire\[UserService\]
}

MacWire работает через wire[T], который сопоставляет зависимости по параметрам конструктора. Это компилируется в обычный new.

5. Использование DI-фреймворков, таких как Google Guice

Можно использовать Google Guice в Scala с минимальной обвязкой:

class UserService @Inject() (db: Database) {
def getUsers(): List\[String\] = db.query("SELECT \* FROM users")
}
class ProdModule extends AbstractModule {
def configure(): Unit = {
bind(classOf\[Database\]).to(classOf\[ProdDatabase\])
}
}

Инициализация:

val injector = Guice.createInjector(new ProdModule())
val service = injector.getInstance(classOf\[UserService\])

Минус — необходимость аннотаций и зависимость от Java-библиотеки.

6. Scaldi (DI-фреймворк для Scala)

Scaldi — фреймворк DI, написанный на Scala и использующий DSL:

class MyModule extends Module {
bind\[Database\] to new ProdDatabase
bind\[UserService\] to injected\[UserService\]
}
implicit val injector = new MyModule
val service = inject\[UserService\]

Scaldi использует inject[T], чтобы извлекать зависимости. Более естественно вписывается в Scala-код, чем Guice.

7. Tagless Final

Используется в функциональном программировании как альтернатива DI.

trait Console\[F\[\_\]\] {
def putStrLn(s: String): F\[Unit\]
}
class Program\[F\[\_\]: Console\] {
def run(): F\[Unit\] = implicitly\[Console\[F\]\].putStrLn("Hello")
}

Инстанс можно подставить для любой монады (IO, Future, Id). Это работает как DI без DI-контейнера, при этом зависимости доступны через типы.

8. Manual wiring (ручная сборка)

Можно просто собрать зависимости вручную:

val db = new ProdDatabase
val userService = new UserService(db)
val app = new App(userService)

Этот способ подходит для небольших приложений, где нет необходимости в гибкой архитектуре.

Сравнение подходов

Подход Гибкость Явность Простой для новичков Поддержка тестов Производительность
Конструктор ★★★★☆ ★★★★★ ★★★★★ ★★★★★ ★★★★★
--- --- --- --- --- ---
Self-type / Cake Pattern ★★★★☆ ★★★★☆ ★★☆☆☆ ★★★★☆ ★★★★★
--- --- --- --- --- ---
Implicit ★★★★☆ ★★☆☆☆ ★★★☆☆ ★★★★☆ ★★★★★
--- --- --- --- --- ---
MacWire ★★★★★ ★★★★☆ ★★★★☆ ★★★★☆ ★★★★★
--- --- --- --- --- ---
Guice / Scaldi ★★★☆☆ ★★★☆☆ ★★★☆☆ ★★★☆☆ ★★★☆☆
--- --- --- --- --- ---
Tagless Final ★★★★★ ★★★★★ ★☆☆☆☆ ★★★★★ ★★★★★
--- --- --- --- --- ---

Выбор зависит от стиля (FP или ООП), размера проекта и предпочтений команды.