Внедрение зависимостей: конструкторное против полевого

Зависимости можно внедрять через конструктор, сеттер или поле. Современный стандарт — конструкторное внедрение. Разберём, почему именно оно.
Суть: внедряйте зависимости через конструктор в final-поля. Это даёт неизменяемость, явный список зависимостей и лёгкое тестирование без Spring.

Внедрение зависимостей (Dependency Injection) — способ передать бину его зависимости извне, вместо того чтобы он создавал их сам. Spring поддерживает три варианта, но они не равноценны: индустрия 2024–2025 годов однозначно рекомендует конструкторное.

Конструкторное внедрение — рекомендуемое

@Service
public class OrderService {

    private final UserRepository userRepo;
    private final PaymentClient payment;

    // Конструктор — Spring сам передаст зависимости
    public OrderService(UserRepository userRepo, PaymentClient payment) {
        this.userRepo = userRepo;
        this.payment = payment;
    }
}

Поля final — значит, после создания их нельзя переназначить (неизменяемость). Если бин имеет один конструктор, @Autowired на нём даже не нужен — Spring внедрит зависимости автоматически.

Полевое внедрение — антипаттерн

@Service
public class OrderService {
    @Autowired
    private UserRepository userRepo;   // НЕ рекомендуется
}

Выглядит короче, но имеет проблемы: поле нельзя сделать final, зависимости не видны в сигнатуре, а в тесте такой объект нельзя создать обычным new без рефлексии или контейнера.

Сравнение

КритерийКонструкторПоле (@Autowired)
final-поляДаНет
Видны зависимостиДа, в сигнатуреНет, скрыты
Тест без SpringПросто newНужна рефлексия
ОбязательностьГарантированаМожет быть null

Как работает под капотом

При создании бина контейнер смотрит на его конструктор, определяет типы параметров, находит подходящие бины в контейнере и передаёт их в конструктор. Это происходит до того, как объект «увидит свет», поэтому к моменту первого использования все зависимости на месте.

  Контейнер создаёт OrderService
        |
        v
  смотрит конструктор: (UserRepository, PaymentClient)
        |
        +-- ищет бин UserRepository  -> найден
        +-- ищет бин PaymentClient   -> найден
        |
        v
  new OrderService(userRepo, payment)  -> готовый бин

Смоделируем автосвязывание по типам параметров конструктора:

# Внедрение зависимостей через "конструктор": подбор по имени-типу
container = {
    "UserRepository": {"name": "userRepo"},
    "PaymentClient": {"name": "paymentClient"},
}

def make_order_service(deps):
    # "конструктор" получает уже готовые зависимости
    return {"deps": deps, "ready": all(deps.values())}

def inject(required_types):
    resolved = {t: container.get(t) for t in required_types}
    missing = [t for t, v in resolved.items() if v is None]
    if missing:
        raise RuntimeError("Не найдены бины: " + ", ".join(missing))
    return make_order_service(resolved)

service = inject(["UserRepository", "PaymentClient"])
print("Сервис собран:", service["ready"])
print(service["deps"])

Нажмите «Попробуй сам ▶»: контейнер подбирает зависимости по типам и передаёт их «в конструктор». Если бина нет — ошибка на старте, а не в рантайме.

Частые ошибки

  • Полевое внедрение «потому что короче». Краткость не стоит потери неизменяемости и тестируемости.
  • Слишком много зависимостей в конструкторе. Если их 7+, класс делает слишком многое — пора разбить.
  • Циклические зависимости. A зависит от B, B — от A: при конструкторном внедрении Spring честно упадёт на старте (и это хорошо — сигнал плохого дизайна).

Best practices

  • Всегда используйте конструкторное внедрение с final-полями.
  • При одном конструкторе @Autowired не пишите — он не нужен.
  • В проектах с Lombok удобен @RequiredArgsConstructor: он сгенерирует конструктор по final-полям.

Итог: конструкторное внедрение — золотой стандарт. Оно делает зависимости явными и обязательными, поля — неизменяемыми, а классы — тестируемыми без подъёма контейнера. Полевое внедрение оставьте в прошлом.

Проверьте себя
1. Почему конструкторное внедрение предпочтительнее полевого (@Autowired на поле)?
AОно работает быстрее в рантайме
BПозволяет final-поля, делает зависимости явными и облегчает тестирование без Spring
CЭто единственный поддерживаемый способ
DОно требует меньше кода во всех случаях
2. Что произойдёт при циклической зависимости (A через конструктор зависит от B, а B от A)?
ASpring создаст оба объекта как null
BSpring при конструкторном внедрении упадёт на старте — это сигнал о плохом дизайне
CПриложение зависнет навсегда
DНичего, Spring это игнорирует