JavaRush /Курсы /Spring Core /Интерфейсы и заменяемые реализации

Интерфейсы и заменяемые реализации

Spring Core
2 уровень , 3 лекция
Открыта

1. Интерфейс как «роль», а не прослойка

Интерфейс полезен не как обязательная прослойка, а как описание роли. Когда разработчик впервые слышит про DI-подход, у него часто возникает вполне естественная мысль: «Ага! Значит, теперь надо сделать интерфейс вообще для всего!» Это как купить шуруповёрт и начать им закручивать суп. Интерфейсы действительно мощно помогают, но только если вы используете их как описание роли, а не как «магическую формальность» из учебника.

Самый полезный способ думать об интерфейсе такой: интерфейс — это обещание поведения, которое нужно сервису, а реализация — конкретный способ выполнить это обещание. То есть сервису обычно не важно, как именно отправили уведомление — в консоль, по email, по SMS или голубиной почтой, — ему важно, что уведомление можно отправить. Как только вы начинаете формулировать зависимость именно так, DI-код становится гибким: детали меняются, а сценарий остаётся на месте.

Посмотрим на минимальный пример. Сначала — «жёсткая привязка» к классу:

public class ConsoleNotificationSender {
    public void send(String text) {
        // Инфраструктурная деталь: конкретно "консольный" канал отправки
        System.out.println("[NOTIFY] " + text); // [NOTIFY] Order created: ORD-1
    }
}

public class OrderPlacementService {
    // Плохая связь: сервис зависит от конкретного класса, а не от роли
    private final ConsoleNotificationSender sender;

    public OrderPlacementService(ConsoleNotificationSender sender) {
        // DI вроде есть (зависимость приходит извне), но абстракции роли нет
        this.sender = sender;
    }
}

DI вроде есть — зависимость приходит извне, — но роль всё равно не отделена от реализации. Теперь чуть аккуратнее: зависимость от роли.

public interface NotificationSender {
    // Контракт роли: "умеет отправлять уведомление"
    void send(String text);
}

public class OrderPlacementService {
    // Хорошая связь: сервис зависит от роли, а не от конкретной детали
    private final NotificationSender sender;

    public OrderPlacementService(NotificationSender sender) {
        // Реализацию выберут снаружи (в composition root)
        this.sender = sender;
    }
}

Сервис не «знает» конкретный класс, он знает только контракт. И это не философия ради философии, а практический ключ к заменяемости.

Абстракция в ContextFlow: где нужна

Один из самых важных навыков — и для Junior это прям суперспособность — отличать «точку вариативности» от «просто класса». Абстракция оправдана там, где вы действительно допускаете разные варианты поведения, либо там, где хотите изолировать инфраструктуру от бизнес-логики. В ContextFlow таких мест довольно много — и это не случайно: проект специально выбран так, чтобы эти точки были естественными.

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

Ниже — небольшая «карта ролей» нашего приложения. Это не список «обязательных интерфейсов по ГОСТу», а подсказка, где абстракция реально помогает:

Роль (что нужно сервису) Интерфейс Примеры реализаций Почему может меняться
Хранить заказы OrderStore InMemoryOrderStore, позже могли бы быть FileOrderStore Среда, тесты, способ хранения
Писать бизнес-аудит AuditWriter ConsoleAuditWriter, NoOpAuditWriter В тестах «молчать», в демо писать в файл/консоль
Отправлять уведомления NotificationSender ConsoleNotificationSender, EmailNotificationSender Каналы отличаются, но роль одна
Считать скидку DiscountPolicy NoDiscountPolicy, LoyalCustomerDiscountPolicy Бизнес-правила меняются чаще всего
Генерировать id OrderIdGenerator UuidOrderIdGenerator, DeterministicOrderIdGenerator Тестам нужна предсказуемость

Ключевой момент: все эти роли — зависимости, без которых сценарий либо невозможен, либо быстро превращается в «комбайн на 400 строк if-else». Мы не пытаемся «ввести интерфейсы везде», а добавляем их там, где есть реальные причины: вариативность, тестируемость, изоляция.

2. Порты ContextFlow: OrderStore и другие

Сейчас мы сделаем важный шаг: оформим эти «роли» в отдельные интерфейсы и дадим им первые простые реализации. Это похоже на ситуацию, когда вы перестаёте говорить «принеси мне вот эту конкретную кружку» и начинаете говорить «принеси мне кружку» — а уж какая именно это кружка, решит тот, кто собирает стол, то есть composition root. Да, звучит слегка бытово. Но хороший DI вообще весь про бытовую здравость.

Начнём с уведомлений. Маленький интерфейс — это хорошо. Чтобы граница читалась глазами, я чуть разнесу код по пакетам: отдельно роли, отдельно инфраструктурные реализации. Это всё тот же ContextFlow; здесь важнее увидеть саму границу между контрактом и реализацией, чем держать весь код в одном пакете.

package com.example.contextflow.domain.ports;

public interface NotificationSender {
    // Порт домена: бизнес-коду важно "можно отправить", а не "куда именно"
    void send(String text);
}

Реализация для консоли — самая «учебная» и честная:

package com.example.contextflow.infrastructure.notification;

import com.example.contextflow.domain.ports.NotificationSender;

public class ConsoleNotificationSender implements NotificationSender {
    public void send(String text) {
        // Инфраструктура: выводим уведомление в stdout
        System.out.println("[NOTIFY] " + text); // [NOTIFY] Order created: ORD-1
    }
}

Теперь аудит. Снова одна и та же идея: роль одна — «записать бизнес-событие», а куда именно — сервису неважно.

package com.example.contextflow.domain.ports;

public interface AuditWriter {
    // Порт домена: фиксируем бизнес-событие (хранение/вывод — деталь)
    void write(String message);
}

Консольный вариант:

package com.example.contextflow.infrastructure.audit;

import com.example.contextflow.domain.ports.AuditWriter;

public class ConsoleAuditWriter implements AuditWriter {
    public void write(String message) {
        // Инфраструктура: пишем аудит в консоль
        System.out.println("[AUDIT] " + message); // [AUDIT] created ORD-1
    }
}

И «тихий» вариант — очень полезный для тестов и сценариев без лишнего шума:

package com.example.contextflow.infrastructure.audit;

import com.example.contextflow.domain.ports.AuditWriter;

public class NoOpAuditWriter implements AuditWriter {
    public void write(String message) {
        // Заглушка: намеренно ничего не делаем (удобно для тестов/тихих сценариев)
    }
}

Хранилище заказов — тоже роль. Для начала нам хватит in-memory-варианта:

package com.example.contextflow.domain.ports;

import com.example.contextflow.domain.model.Order;
import java.util.Optional;

public interface OrderStore {
    // Сохраняем заказ (здесь нет деталей: БД/файл/память — всё равно)
    void save(Order order);

    // Возвращаем Optional, чтобы отсутствие заказа было явным случаем
    Optional<Order> findById(String orderId);
}

Простейшая реализация на Map:

package com.example.contextflow.infrastructure.store;

import com.example.contextflow.domain.model.Order;
import com.example.contextflow.domain.ports.OrderStore;

import java.util.HashMap;
import java.util.Map;
import java.util.Optional;

public class InMemoryOrderStore implements OrderStore {
    // В учебном режиме храним всё в памяти процесса
    private final Map<String, Order> orders = new HashMap<>();

    public void save(Order order) {
        // Ключ — id заказа, значение — сам заказ
        orders.put(order.getId(), order);
    }

    public Optional<Order> findById(String orderId) {
        // Оборачиваем результат в Optional: null наружу не "протекает"
        return Optional.ofNullable(orders.get(orderId));
    }
}

Скидка. Здесь важно не увлечься и не построить «движок скидок» — это отдельная профессия и отдельная боль. Нам нужна роль и две реализации: «нет скидки» и «есть скидка лояльным».

package com.example.contextflow.domain.ports;

import com.example.contextflow.domain.model.Customer;

public interface DiscountPolicy {
    // Возвращаем сумму скидки (в условных "денежных единицах"), а не итоговую цену
    int discountFor(Customer customer, int total);
}

Без скидки:

package com.example.contextflow.application.discount;

import com.example.contextflow.domain.model.Customer;
import com.example.contextflow.domain.ports.DiscountPolicy;

public class NoDiscountPolicy implements DiscountPolicy {
    public int discountFor(Customer customer, int total) {
        // Политика по умолчанию: скидок нет
        return 0;
    }
}

Скидка лояльным:

package com.example.contextflow.application.discount;

import com.example.contextflow.domain.model.Customer;
import com.example.contextflow.domain.ports.DiscountPolicy;

public class LoyalCustomerDiscountPolicy implements DiscountPolicy {
    public int discountFor(Customer customer, int total) {
        // Пример бизнес-правила: лояльному клиенту 10% от суммы
        return customer.isLoyal() ? (total / 10) : 0; // 10%
    }
}

И генератор id. Это вообще классический пример того, почему интерфейс полезен: в «боевом» режиме вы хотите UUID, а в тестах — детерминированность.

package com.example.contextflow.domain.ports;

public interface OrderIdGenerator {
    // Роль: выдать следующий идентификатор (деталь генерации — не забота сервиса)
    String nextId();
}

Детерминированный вариант:

package com.example.contextflow.infrastructure.id;

import com.example.contextflow.domain.ports.OrderIdGenerator;

public class DeterministicOrderIdGenerator implements OrderIdGenerator {
    // Счётчик для предсказуемых id — особенно удобно в тестах
    private int seq = 1;

    public String nextId() {
        // Предсказуемая последовательность: ORD-1, ORD-2, ...
        return "ORD-" + (seq++);
    }
}

На этом этапе вы можете заметить, что интерфейсов стало больше. Это нормально. Главное — что каждый из них отвечает на вопрос «какую роль сервис ожидает получить?», а не существует «потому что так надо».

3. Переключение поведения через composition root

Интерфейсы прекрасны ровно до того момента, пока вы не попробуете ими реально воспользоваться. То есть пока не замените одну реализацию на другую и не увидите, что бизнес-код не дрогнул ни на строчку. Именно ради этого мы вообще вводим абстракции: чтобы изменения происходили там, где им и место — в точке сборки, а не внутри сценариев.

Сделаем упрощённый OrderPlacementService, который зависит от двух ролей: уведомление и аудит. Обратите внимание: сервис «разговаривает» с интерфейсами, а не с конкретными классами.

package com.example.contextflow.application.service;

import com.example.contextflow.domain.ports.AuditWriter;
import com.example.contextflow.domain.ports.NotificationSender;

public class OrderPlacementService {
    // Роли, которые нужны сценарию: уведомить и записать аудит
    private final NotificationSender sender;
    private final AuditWriter auditWriter;

    public OrderPlacementService(NotificationSender sender, AuditWriter auditWriter) {
        // Конкретные реализации приходят снаружи
        this.sender = sender;
        this.auditWriter = auditWriter;
    }

    public void place(String orderId) {
        // Бизнес-сценарий не знает, КАК отправляют и КУДА пишут аудит — он просто вызывает роли
        sender.send("Order created: " + orderId);
        auditWriter.write("created " + orderId);
    }
}

Теперь композиция «для обычного запуска»:

package com.example.contextflow;

import com.example.contextflow.application.service.OrderPlacementService;
import com.example.contextflow.infrastructure.audit.ConsoleAuditWriter;
import com.example.contextflow.infrastructure.notification.ConsoleNotificationSender;

public class Main {
    public static void main(String[] args) {
        // Composition root: здесь выбираем конкретные реализации ролей
        OrderPlacementService service = new OrderPlacementService(
                new ConsoleNotificationSender(),
                new ConsoleAuditWriter()
        );

        // Запускаем сценарий
        service.place("ORD-1");
        // [NOTIFY] Order created: ORD-1
        // [AUDIT] created ORD-1
    }
}

А теперь — самое вкусное: меняем поведение, не меняя сервис. Например, хотим «тихий режим» — условно тестовый сценарий, в котором аудит выключен.

package com.example.contextflow;

import com.example.contextflow.application.service.OrderPlacementService;
import com.example.contextflow.infrastructure.audit.NoOpAuditWriter;
import com.example.contextflow.infrastructure.notification.ConsoleNotificationSender;

public class Main {
    public static void main(String[] args) {
        // Меняем только wiring: теперь аудит "молчит"
        OrderPlacementService service = new OrderPlacementService(
                new ConsoleNotificationSender(),
                new NoOpAuditWriter()
        );

        service.place("ORD-2");
        // [NOTIFY] Order created: ORD-2
    }
}

Вот это и есть настоящая «победа» DI-дизайна. Мы не переписывали OrderPlacementService, не добавляли туда флаги boolean auditEnabled, не устраивали if(mode == TEST). Мы просто сказали: «В этот раз роль AuditWriter будет исполнять другой объект».

Чтобы закрепить картинку, вот схема графа объектов (и да, это всё ещё plain Java, просто мы описали его аккуратно):

flowchart LR
    Main["Main (composition root)"] --> OPS["OrderPlacementService"]
    OPS -->|NotificationSender| CNS["ConsoleNotificationSender"]
    OPS -->|AuditWriter| CAW["ConsoleAuditWriter / NoOpAuditWriter"]

И именно из-за таких схем в реальных проектах люди любят контейнеры: граф растёт, и вручную управлять всем становится тяжело. Но пока мы честно учимся делать это руками — чтобы понимать, что именно контейнер потом будет автоматизировать.

4. Где интерфейс лишний: доменные данные и команды

После нескольких удачных интерфейсов есть риск перейти в режим «интерфейсомании». Это когда вы делаете Order, IOrder, OrderImpl, потом Customer, ICustomer, DefaultCustomer… и внезапно приложением становится проще управлять через шаманский бубен, чем через IDE. Чтобы так не произошло, важно заранее провести границу: данные и простые доменные модели обычно не являются точкой вариативности.

Заказ — это данные. Клиент — это данные. Команда создания заказа — это входные данные. Они не должны «меняться реализациями», они должны быть понятными и простыми. Поэтому чаще всего это обычные классы (или record’ы, если вы их любите).

Пример минимального Order без интерфейса:

package com.example.contextflow.domain.model;

public class Order {
    // Доменная модель данных: простой, прозрачный объект без вариативности реализаций
    private final String id;
    private final int total;

    public Order(String id, int total) {
        this.id = id;
        this.total = total;
    }

    public String getId() { return id; }
    public int getTotal() { return total; }
}

И простой Customer, который нужен скидкам:

package com.example.contextflow.domain.model;

public class Customer {
    // Снова: простые данные, без интерфейсов и "плагинов"
    private final String id;
    private final boolean loyal;

    public Customer(String id, boolean loyal) {
        this.id = id;
        this.loyal = loyal;
    }

    public boolean isLoyal() { return loyal; }
}

Заметьте, как хорошо это читается: нет «пустых абстракций». Да, можно представить, что когда-то вы захотите разные реализации Customer… но обычно это означает, что вы моделируете не Customer, а разные источники данных или разные представления клиента — и это уже другая тема. На текущем уровне нам нужно, чтобы доменная модель была прозрачной, а абстракции жили там, где они действительно управляют сборкой.

5. Признаки плохого интерфейса и быстрые правки

Интерфейс — штука простая, но плохой интерфейс быстро делает проект неприятным. Причём неприятным не для компьютера — компьютер всё стерпит, — а для человека, который читает код. Хорошая новость в том, что большинство проблем видно на уровне здравого смысла: если вы смотрите на интерфейс и не можете объяснить, какую роль он выражает, скорее всего он и правда выражает «ничего».

Есть два популярных анти-паттерна. Первый — интерфейс ради интерфейса: «у каждого класса должен быть интерфейс». Тогда появляются пары OrderService / OrderServiceImpl, где интерфейс не даёт вариативности, а просто добавляет слой. Второй — «интерфейс-комбайн», который пытается включить всё, что когда-то могло понадобиться, и в итоге становится маленькой вселенной с десятком методов.

Пример интерфейса-комбайна — так делать не хочется:

public interface OrderStore {
    void save(Object o);
    void delete(Object o);
    void clearAll();
    void printDebugInfo();
    String exportToJson();
}

Проблема тут не в количестве строк, а в смешении ролей. Хранилище вдруг стало и экспортёром, и дебаггером, и уборщиком. Исправление часто простое: вернуть интерфейс к роли. Например, для нашего учебного проекта достаточно «сохранить» и «найти»:

import java.util.Optional;

public interface OrderStore {
    // Узкая роль: хранить заказы (сохранить и найти)
    void save(Order order);

    Optional<Order> findById(String orderId);
}

Ещё один хороший тест: попробуйте произнести интерфейс вслух как предложение. «OrderStore сохраняет и находит заказы». «AuditWriter пишет записи аудита». «NotificationSender отправляет уведомления». Если получается нормальная фраза — вы близки к хорошей абстракции. Если получается «OrderManagerDoEverythingProviderFactory» — возможно, вы случайно начали писать заклинание, а не код.

6. Типичные ошибки при введении интерфейсов

Ошибка №1: интерфейсы “на всякий случай” для каждого класса.
Очень легко скатиться в шаблон «есть класс — значит должен быть интерфейс». В итоге появляются пустые абстракции, которые не дают ни вариативности, ни ясности, но усложняют навигацию по коду. Лучше вводить интерфейс там, где есть реальная роль и хотя бы потенциально несколько реализаций.

Ошибка №2: названия по реализации, а не по роли.
Если интерфейс называется ConsoleAuditWriter, то это уже не интерфейс роли — это интерфейс детали. Интерфейс должен быть про «что делаем»: AuditWriter, NotificationSender, OrderStore. А конкретика — “console”, “file”, “noop” — живёт в имени реализации.

Ошибка №3: бизнес-сервис всё равно выбирает реализацию через if.
Иногда делают NotificationSender интерфейс… а потом внутри OrderPlacementService пишут if(channel.equals("console")) sender = new Console.... Это возвращает нас к той же боли, от которой мы уходили: класс снова принимает решение о реализации. Выбор реализации должен жить в composition root — в точке сборки, — а не внутри сценария.

Ошибка №4: интерфейс стал “богом”, потому что так удобнее одному сервису.
Если один сервис захотел «ещё один метод» и вы добавили его в общий интерфейс, вы заставили все реализации поддерживать эту новую обязанность. Отсюда растут странные конструкции вроде throw new UnsupportedOperationException() или фейковые заглушки. Часто лучше сделать второй маленький интерфейс под отдельную роль, чем раздувать один.

Ошибка №5: интерфейсы “протекли” в доменные данные.
Когда делают IOrder, ICustomer, IOrderItem, обычно это не делает систему гибче — зато делает её менее читаемой. Доменные объекты данных почти всегда остаются обычными классами, а интерфейсы появляются вокруг внешних эффектов — уведомления, аудит, хранение — или вокруг вариативных стратегий — скидка, генерация id.

1
Задача
Spring Core, 2 уровень, 3 лекция
Недоступна
Заменяемый отправитель уведомлений
Заменяемый отправитель уведомлений
1
Задача
Spring Core, 2 уровень, 3 лекция
Недоступна
Интерфейс для поведения, record для данных
Интерфейс для поведения, record для данных
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ