D - Dependency Inversion: зависимости наружу

D - Dependency Inversion: зависимости наружу

DIP: зависеть нужно от абстракций, а не от деталей.

Чтобы бизнес‑логика не была приклеена к PostgreSQL, Redis и внешним API скотчем.

Плохой пример

type OrderService struct {
  db *sql.DB
}

func (s *OrderService) Create(userID int64) error {
  // напрямую пишем SQL
  _, err := s.db.Exec("INSERT INTO orders(user_id) VALUES ($1)", userID)
  return err
}
final class OrderService {
  public function __construct(private PDO $db) {}

  public function create(int $userId): void {
    // напрямую дёргаем PDO
    $stmt = $this->db->prepare('INSERT INTO orders(user_id) VALUES (?)');
    $stmt->execute([$userId]);
  }
}
class OrderService {
  #db;
  constructor(db) { this.#db = db; }

  async create(userId) {
    // напрямую дёргаем драйвер БД
    await this.#db.query('INSERT INTO orders(user_id) VALUES ($1)', [userId]);
  }
}

Тестировать без базы? Ну... удачи.

Лучше: зависимость от интерфейса

UserService напрямую создаёт Postgres против инверсии через интерфейс UserRepo

type OrderRepo interface { Create(userID int64) error }

type OrderService struct {
  repo OrderRepo
}

func (s *OrderService) Create(userID int64) error {
  if userID <= 0 { return fmt.Errorf("bad user") }
  return s.repo.Create(userID)
}

Реализация для Postgres (деталь):

type PgOrderRepo struct { db *sql.DB }
func (r PgOrderRepo) Create(userID int64) error {
  _, err := r.db.Exec("INSERT INTO orders(user_id) VALUES ($1)", userID)
  return err
}

Тестовая реализация (фейк):

type FakeOrderRepo struct{ Calls int }
func (r *FakeOrderRepo) Create(userID int64) error { r.Calls++; return nil }
interface OrderRepo
{
    public function create(int $userId): void;
}

final class OrderService
{
    public function __construct(
        private readonly OrderRepo $repo,
    ) {}

    public function create(int $userId): void
    {
        if ($userId <= 0) {
            throw new InvalidArgumentException('bad user');
        }
        $this->repo->create($userId);
    }
}

Реализация для Postgres (деталь):

final class PdoOrderRepo implements OrderRepo
{
    public function __construct(
        private readonly PDO $db,
    ) {}

    public function create(int $userId): void
    {
        $this->db->prepare('INSERT INTO orders(user_id) VALUES (?)')->execute([$userId]);
    }
}

Тестовая реализация (фейк):

final class FakeOrderRepo implements OrderRepo
{
    public int $calls = 0;

    public function create(int $userId): void
    {
        $this->calls++;
    }
}
/** @typedef {{ create: (userId: number) => Promise<void> }} OrderRepo */

class OrderService {
  #repo;
  /** @param {OrderRepo} repo */
  constructor(repo) { this.#repo = repo; }

  async create(userId) {
    if (userId <= 0) throw new Error('bad user');
    await this.#repo.create(userId);
  }
}

Реализация для Postgres (деталь):

class PgOrderRepo {
  #db;
  constructor(db) { this.#db = db; }
  async create(userId) {
    await this.#db.query('INSERT INTO orders(user_id) VALUES ($1)', [userId]);
  }
}

Тестовая реализация (фейк):

class FakeOrderRepo {
  calls = 0;
  async create(_userId) { this.calls++; }
}
Абстракции нужны там, где есть вариативность или где ты хочешь тестировать без тяжёлых зависимостей.

DIP в Hexagonal Architecture

DIP - это краеугольный камень гексагональной архитектуры (другое название - ports and adapters). Идея простая:

  • В центре - домен (бизнес-правила) и use cases, которые владеют интерфейсами своих зависимостей (OrderRepo, Mailer, PaymentGateway)
  • На периферии - адаптеры: реализации этих интерфейсов поверх Postgres, SMTP, Stripe
  • Зависимости идут снаружи внутрь: адаптеры знают про домен, домен про адаптеры - нет

Это и есть «инверсия» в DIP: естественное направление зависимостей в коде (бизнес → инфраструктура) разворачивается, и инфраструктура зависит от бизнеса. Это та же слоистая структура проекта, которую разбирали в clean-code: домен и use cases импортируют только Go stdlib и собственные интерфейсы, а пакеты infra/ подставляют реализации.

Constructor injection в Go: почему через конструктор

Способов «впрыснуть» зависимость в Go два - через поле структуры или через конструктор. Идиома проекта - constructor injection:

// Плохо: зависимость можно забыть выставить, и сервис упадёт в рантайме
type OrderService struct {
    Repo OrderRepo // public - внешний код может оставить nil
}

// Хорошо: конструктор гарантирует, что без зависимости сервис не создать
type OrderService struct {
    repo OrderRepo // unexported
}

func NewOrderService(repo OrderRepo) *OrderService {
    return &OrderService{repo: repo}
}
<?php
declare(strict_types=1);

// Плохо: setter injection - сервис можно создать без репозитория и упасть в рантайме
final class OrderService
{
    private OrderRepo $repo;

    public function setRepo(OrderRepo $repo): void { $this->repo = $repo; }
}

// Хорошо: constructor injection через property promotion.
// Без зависимости сервис не создать; Symfony DI собирает граф автоматически.
final class OrderService
{
    public function __construct(
        private readonly OrderRepo $repo,
    ) {}
}
// Плохо: setter injection - сервис можно создать без репозитория
class OrderService {
  repo; // public - внешний код может оставить undefined

  setRepo(repo) { this.repo = repo; }
}

// Хорошо: constructor injection. Без зависимости сервис не создать.
// NestJS / InversifyJS собирают граф зависимостей по сигнатуре конструктора.
class OrderService {
  #repo;

  /** @param {OrderRepo} repo */
  constructor(repo) { this.#repo = repo; }
}

Конструктор - это контракт: он явно перечисляет, без чего сервис не работает. Это полезно и для читаемости, и для статических проверок (DI-фреймворки умеют по сигнатуре конструктора собирать граф зависимостей).

DIP в проекте: Wire DI

BackendStart использует Google Wire для сборки графа зависимостей на этапе компиляции. Wire читает конструкторы и генерирует код, который их склеивает - никаких рантайм-рефлексий.

// internal/usecase/order_service.go
type OrderRepo interface {
    Save(ctx context.Context, o Order) error
}

func NewOrderService(repo OrderRepo, log *slog.Logger) *OrderService {
    return &OrderService{repo: repo, log: log}
}

// internal/wire/wire.go (упрощённо)
func InitializeApp(db *sql.DB, log *slog.Logger) *App {
    wire.Build(
        infra.NewPgOrderRepo, // *sql.DB → OrderRepo
        usecase.NewOrderService,
        NewApp,
    )
    return nil
}
<?php
declare(strict_types=1);

// PHP-эквивалент - Symfony DI контейнер с autowiring.
// Сборка графа происходит в compile-time при cache:warmup,
// а не в рантайме на каждый запрос.

namespace App\UseCase;

use Psr\Log\LoggerInterface;

interface OrderRepo
{
    public function save(Order $order): void;
}

final class OrderService
{
    public function __construct(
        private readonly OrderRepo $repo,
        private readonly LoggerInterface $logger,
    ) {}
}
# config/services.yaml
services:
    _defaults:
        autowire: true
        autoconfigure: true

    App\:
        resource: '../src/'

    # Связывание интерфейса с реализацией
    App\UseCase\OrderRepo:
        class: App\Infrastructure\Doctrine\DoctrineOrderRepo
// NestJS - стандартный DI-фреймворк для Node.js. Контейнер строится из метаданных
// @Injectable и параметров конструктора (через TypeScript decorators или JSDoc).

import { Injectable, Inject } from '@nestjs/common';

/** @typedef {{ save: (order: Order) => Promise<void> }} OrderRepo */

@Injectable()
class OrderService {
  #repo;
  #logger;

  /**
   * @param {OrderRepo} repo
   * @param {Logger}    logger
   */
  constructor(@Inject('OrderRepo') repo, logger) {
    this.#repo = repo;
    this.#logger = logger;
  }
}

// app.module.ts связывает интерфейс с реализацией:
// providers: [
//   { provide: 'OrderRepo', useClass: PgOrderRepo },
//   OrderService,
// ]

Wire генерирует функцию-инициализатор и проверяет, что все зависимости разрешимы. Если интерфейс OrderRepo не имеет реализации - компиляция упадёт. В Symfony аналог - bin/console lint:container и cache:warmup: контейнер падает при сборке, а не при первом запросе. DIP здесь не «надстройка», а свойство проектной структуры.

Где DIP вредит: правило трёх

Не каждая зависимость заслуживает интерфейса. Признаки, что DIP здесь - над-инжиниринг:

  • Один handler вызывает одну конкретную функцию из stdlib (json.Marshal, time.Now) - заворачивать это в интерфейс не нужно
  • В тесте подходит реальная реализация (например, crypto/sha256 - детерминированная и быстрая, мокать незачем)
  • Реализация одна и не предвидится второй до конца квартала - добавишь интерфейс, когда появится реальный второй кейс

DIP включается там, где есть реальная вариативность или внешняя зависимость, тяжёлая для теста (БД, сеть, файловая система, время, рандом).

Тестируемость как побочный эффект DIP

Главный практический выигрыш DIP - компактные unit-тесты бизнес-логики (в PHP аналогично - PHPUnit):

type fakeOrderRepo struct {
    saved []Order
    err   error
}

func (f *fakeOrderRepo) Save(_ context.Context, o Order) error {
    if f.err != nil { return f.err }
    f.saved = append(f.saved, o)
    return nil
}

func TestOrderService_Create(t *testing.T) {
    repo := &fakeOrderRepo{}
    svc := NewOrderService(repo, slog.Default())

    err := svc.Create(context.Background(), 42)

    require.NoError(t, err)
    require.Len(t, repo.saved, 1)
    require.Equal(t, int64(42), repo.saved[0].UserID)
}
<?php
declare(strict_types=1);

namespace App\Tests\UseCase;

use App\UseCase\Order;
use App\UseCase\OrderRepo;
use App\UseCase\OrderService;
use PHPUnit\Framework\TestCase;
use Psr\Log\NullLogger;

final class FakeOrderRepo implements OrderRepo
{
    /** @var Order[] */
    public array $saved = [];

    public function save(Order $order): void
    {
        $this->saved[] = $order;
    }
}

final class OrderServiceTest extends TestCase
{
    public function testCreatePersistsOrder(): void
    {
        $repo = new FakeOrderRepo();
        $service = new OrderService($repo, new NullLogger());

        $service->create(42);

        self::assertCount(1, $repo->saved);
        self::assertSame(42, $repo->saved[0]->userId);
    }
}
import { describe, it, expect } from 'vitest';

class FakeOrderRepo {
  saved = [];
  async save(order) { this.saved.push(order); }
}

describe('OrderService', () => {
  it('создаёт заказ и сохраняет его', async () => {
    const repo = new FakeOrderRepo();
    const service = new OrderService(repo, console);

    await service.create(42);

    expect(repo.saved).toHaveLength(1);
    expect(repo.saved[0].userId).toBe(42);
  });
});

Никакой БД, никакой докер-сетки, никаких миграций - тест бежит за миллисекунды и проверяет именно бизнес-логику.

Мини‑задание

  • Выдели интерфейс репозитория в сервисе
  • Напиши фейковую реализацию для теста
  • Перепиши конструктор сервиса так, чтобы все зависимости приходили снаружи (constructor injection)
  • Проверь по своему проекту: импортирует ли пакет usecase/domain что-то из infra/db? Если да - это сигнал, что DIP нарушен

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.