Программа курса← Объектно ориентированное программирование
1История ООП
2Объект и Класс
3Базовые концепции ООП
4Принцип проектирования GRASP
5Принцип проектирования SOLID
6Паттерны GOF
7Методические материалы

Low Coupling и High Cohesion

За основу разбора взята статья Владимира Хорикова «Cohesion and coupling: the difference»

Два предыдущих принципа звучат как прямая противоположность друг другу. High Cohesion требует собрать связанное вместе, Low Coupling – наоборот, всё развязать и разнести. Любой из них легко довести до абсурда поодиночке: свалить систему в один класс – связность предельная; растащить каждый метод в отдельный класс за интерфейсом – зацепления почти нет. Оба предела дают негодный код.

Противоречие исчезает, если заметить, что принципы считают разные связи:

  • связность – это связи внутри модуля, и их должно быть много: что меняется вместе, то и лежит вместе;
  • зацепление – это связи, пересекающие границу модуля, и их должно быть мало.

Одна и та же связь между двумя классами попадёт то в одну корзину, то в другую – смотря по какую сторону границы эти классы оказались. Поэтому спорят принципы не между собой: они вдвоём задают вам один вопрос – где провести границу. Провели по смыслу – связи остались внутри и стали связностью. Провели поперёк смысла – те же самые связи вылезли наружу и стали зацеплением.

Отсюда главное: связи не убирают, их перекладывают. Разрезали класс пополам – связность каждой половины упала, а между половинами появилось зацепление, которого раньше не было.

Четыре угла

Если развести оба свойства по осям, получается четыре типа кода:

Low Coupling и High Cohesion Божественный объект Разрушительное расцепление Плохие границы что меняется вместе – лежит вместе знает и умеет всё, трогать страшно интерфейсы ради интерфейсов, смысл размазан разрезано не по смыслу: слои вместо задач слабое сильное высокая низкая зацепление связность

Дальше – каждый угол по отдельности, от худшего к лучшему. На схемах цвет кружка это смысл (что к чему относится по задаче), а рамка – граница модуля: класса, папки, сервиса. Под каждой схемой – один и тот же заказ из интернет-магазина, разложенный по границам четырьмя разными способами: сначала три способа промахнуться, потом тот, к которому мы идём.

Кружки на схемах живые: их можно таскать и перекидывать – но только внутри своей рамки. Класс не переедет в другой модуль оттого, что вам так удобнее смотреть.

Божественный объект

база почта отчёты планировщик UserManager

Единственный угол, где высокая связность не спасает. Внутри божественного объекта действительно всё связано со всем: методы дёргают друг друга, общие поля читают все подряд, разрезать по живому не выходит. Но и наружу он цепляется за полсистемы: базу, почту, отчёты, планировщик. В литературе такой код называют ещё big ball of mud – большим комом грязи.

Так получается не сразу. Класс начинается нормальным, а дальше каждое «ну куда ещё это положить, пусть пока здесь полежит» добавляет по методу. Через год выходит UserManager на три тысячи строк, у которого пятнадцать причин меняться и ни одной причины, по которой в него не добавить шестнадцатую.

// Тот же заказ, но класс отвечает ещё и за деньги, базу, почту, печать и доставку
public class OrderManager
{
    private readonly List<OrderLine> _lines = new();

    public void AddLine(Product product, int quantity) =>
        _lines.Add(new OrderLine(product, quantity));

    public decimal Total => _lines.Sum(l => l.Cost) - Discount();

    private decimal Discount() => _lines.Count > 10 ? 500 : 0;

    public void Save() =>
        new SqlConnection(Config.Db).Execute("INSERT INTO orders ...", _lines);

    public void SendConfirmation(string email) =>
        new SmtpClient("smtp.example.com").Send("noreply@shop.io", email, "Заказ принят", "...");

    public byte[] RenderInvoicePdf() => PdfBuilder.From(_lines).Build();

    public void ScheduleDelivery(DateTime when) =>
        new Scheduler().Add(new DeliveryJob(_lines, when));
}
// Тот же заказ, но класс отвечает ещё и за деньги, базу, почту, печать и доставку
class OrderManager {
public:
    void AddLine(const Product& product, int quantity) {
        lines_.emplace_back(product, quantity);
    }

    double Total() const { return Sum() - Discount(); }

    void Save() { SqlConnection(Config::Db()).Execute("INSERT INTO orders ...", lines_); }

    void SendConfirmation(const std::string& email) {
        SmtpClient("smtp.example.com").Send("noreply@shop.io", email, "Заказ принят", "...");
    }

    std::vector<char> RenderInvoicePdf() const { return PdfBuilder(lines_).Build(); }

    void ScheduleDelivery(Date when) { Scheduler().Add(DeliveryJob(lines_, when)); }

private:
    double Discount() const { return lines_.size() > 10 ? 500 : 0; }

    std::vector<OrderLine> lines_;
};
// Тот же тип отвечает ещё и за деньги, базу, почту, печать и доставку
type OrderManager struct {
	lines []OrderLine
}

func (m *OrderManager) AddLine(product Product, quantity int) {
	m.lines = append(m.lines, OrderLine{Product: product, Quantity: quantity})
}

func (m *OrderManager) Total() float64 { return m.sum() - m.discount() }

func (m *OrderManager) discount() float64 {
	if len(m.lines) > 10 {
		return 500
	}

	return 0
}

func (m *OrderManager) Save() error {
	return db.Exec("INSERT INTO orders ...", m.lines)
}

func (m *OrderManager) SendConfirmation(email string) error {
	return smtp.SendMail("smtp.example.com:25", nil, "noreply@shop.io", []string{email}, confirmation(m.lines))
}

func (m *OrderManager) RenderInvoicePDF() ([]byte, error) { return pdf.Build(m.lines) }

func (m *OrderManager) ScheduleDelivery(when time.Time) error {
	return scheduler.Add(DeliveryJob{Lines: m.lines, At: when})
}
# Тот же заказ, но класс отвечает ещё и за деньги, базу, почту, печать и доставку
class OrderManager:
    def __init__(self) -> None:
        self._lines: list[OrderLine] = []

    def add_line(self, product: Product, quantity: int) -> None:
        self._lines.append(OrderLine(product, quantity))

    @property
    def total(self) -> float:
        return sum(line.cost for line in self._lines) - self._discount()

    def _discount(self) -> float:
        return 500 if len(self._lines) > 10 else 0

    def save(self) -> None:
        psycopg.connect(Config.DB).execute("INSERT INTO orders ...", self._lines)

    def send_confirmation(self, email: str) -> None:
        smtplib.SMTP("smtp.example.com").sendmail("noreply@shop.io", email, "...")

    def render_invoice_pdf(self) -> bytes:
        return PdfBuilder(self._lines).build()

    def schedule_delivery(self, when: datetime) -> None:
        Scheduler().add(DeliveryJob(self._lines, when))
// Тот же заказ, но класс отвечает ещё и за деньги, базу, почту, печать и доставку
class OrderManager {
    private readonly lines: OrderLine[] = [];

    addLine(product: Product, quantity: number): void {
        this.lines.push(new OrderLine(product, quantity));
    }

    get total(): number {
        return this.lines.reduce((sum, line) => sum + line.cost, 0) - this.discount();
    }

    private discount(): number {
        return this.lines.length > 10 ? 500 : 0;
    }

    save(): void {
        db.execute('INSERT INTO orders ...', this.lines);
    }

    sendConfirmation(email: string): void {
        new SmtpClient('smtp.example.com').send('noreply@shop.io', email, 'Заказ принят', '...');
    }

    renderInvoicePdf(): Buffer {
        return new PdfBuilder(this.lines).build();
    }

    scheduleDelivery(when: Date): void {
        scheduler.add(new DeliveryJob(this.lines, when));
    }
}
// Тот же заказ, но класс отвечает ещё и за деньги, базу, почту, печать и доставку
public class OrderManager {
    private final List<OrderLine> lines = new ArrayList<>();

    public void addLine(Product product, int quantity) {
        lines.add(new OrderLine(product, quantity));
    }

    public BigDecimal getTotal() {
        return sum().subtract(discount());
    }

    private BigDecimal discount() {
        return lines.size() > 10 ? BigDecimal.valueOf(500) : BigDecimal.ZERO;
    }

    public void save() {
        db.execute("INSERT INTO orders ...", lines);
    }

    public void sendConfirmation(String email) {
        new SmtpClient("smtp.example.com").send("noreply@shop.io", email, "Заказ принят", "...");
    }

    public byte[] renderInvoicePdf() {
        return new PdfBuilder(lines).build();
    }

    public void scheduleDelivery(LocalDate when) {
        scheduler.add(new DeliveryJob(lines, when));
    }
}
// Тот же заказ, но класс отвечает ещё и за деньги, базу, почту, печать и доставку
class OrderManager {
    private val lines = mutableListOf<OrderLine>()

    fun addLine(product: Product, quantity: Int) {
        lines += OrderLine(product, quantity)
    }

    val total: Double get() = lines.sumOf { it.cost } - discount()

    private fun discount() = if (lines.size > 10) 500.0 else 0.0

    fun save() = db.execute("INSERT INTO orders ...", lines)

    fun sendConfirmation(email: String) =
        SmtpClient("smtp.example.com").send("noreply@shop.io", email, "Заказ принят", "...")

    fun renderInvoicePdf(): ByteArray = PdfBuilder(lines).build()

    fun scheduleDelivery(at: LocalDate) = scheduler.add(DeliveryJob(lines, at))
}

Каждый метод по отдельности написан нормально – проблема в том, что они собрались в одном классе. Скидки приходят от маркетинга, шаблон письма от поддержки, формат счёта от бухгалтерии, расписание доставки от логистики: пять хозяев у одного файла.

Чем это оборачивается:

  • любая правка рискованная – метод, который вы трогаете, читают ещё двадцать соседних, и что отвалится, заранее не видно;
  • тестировать нечего по частям – чтобы проверить одну функцию, нужно поднять базу, почту и всё остальное;
  • работать вдвоём невозможно – весь отдел правит один файл и разбирает конфликты;
  • переиспользовать нельзя вообще – класс тащит за собой всю систему;
  • новичок в нём тонет – чтобы понять один сценарий, приходится прочитать три тысячи строк.

Лечится он не тем, что «разрезать на классы поменьше»: нарежете не по смыслу – переедете в соседний угол, к плохим границам. Резать нужно по причинам изменения, и об этом – весь модуль GRASP: Information Expert подсказывает, кому отдать данные, Creator – кому создание, Controller – кому приём событий.

Плохие границы

Services Helpers

Классика «разрезали по слоям»: Services, Helpers, Utils, Models. Рамки есть, но проведены не по смыслу задачи, а по типу файла – и в каждой лежат кусочки разных задач. Связи, которые должны были остаться внутренними, теперь пересекают границы.

// Models: данные без поведения
public class OrderModel
{
    public List<OrderLineModel> Lines { get; set; } = new();
    public bool IsPaid { get; set; }
}

// Helpers: поведение заказа, вынесенное из заказа
public static class OrderHelper
{
    public static decimal CalculateTotal(OrderModel order) =>
        order.Lines.Sum(l => l.Price * l.Quantity);
}

// Services: сценарий, которому приходится ходить и туда, и сюда
public class OrderService
{
    public void Confirm(OrderModel order)
    {
        var total = OrderHelper.CalculateTotal(order);
        order.IsPaid = PaymentHelper.Charge(total);
    }
}
// Models: данные без поведения
struct OrderModel {
    std::vector<OrderLineModel> lines;
    bool is_paid = false;
};

// Helpers: поведение заказа, вынесенное из заказа
namespace order_helper {
double CalculateTotal(const OrderModel& order) {
    double total = 0;
    for (const auto& line : order.lines) total += line.price * line.quantity;

    return total;
}
}  // namespace order_helper

// Services: сценарий, которому приходится ходить и туда, и сюда
class OrderService {
public:
    void Confirm(OrderModel& order) {
        order.is_paid = payment_helper::Charge(order_helper::CalculateTotal(order));
    }
};
// Models: данные без поведения
type OrderModel struct {
	Lines  []OrderLineModel
	IsPaid bool
}

// Helpers: поведение заказа, вынесенное из заказа
func CalculateTotal(order OrderModel) float64 {
	total := 0.0
	for _, line := range order.Lines {
		total += line.Price * float64(line.Quantity)
	}

	return total
}

// Services: сценарий, которому приходится ходить и туда, и сюда
type OrderService struct{}

func (OrderService) Confirm(order *OrderModel) error {
	paid, err := Charge(CalculateTotal(*order))
	order.IsPaid = paid

	return err
}
# Models: данные без поведения
@dataclass
class OrderModel:
    lines: list[OrderLineModel] = field(default_factory=list)
    is_paid: bool = False

# Helpers: поведение заказа, вынесенное из заказа
def calculate_total(order: OrderModel) -> float:
    return sum(line.price * line.quantity for line in order.lines)

# Services: сценарий, которому приходится ходить и туда, и сюда
class OrderService:
    def confirm(self, order: OrderModel) -> None:
        order.is_paid = charge(calculate_total(order))
// Models: данные без поведения
interface OrderModel {
    lines: OrderLineModel[];
    isPaid: boolean;
}

// Helpers: поведение заказа, вынесенное из заказа
function calculateTotal(order: OrderModel): number {
    return order.lines.reduce((sum, line) => sum + line.price * line.quantity, 0);
}

// Services: сценарий, которому приходится ходить и туда, и сюда
class OrderService {
    confirm(order: OrderModel): void {
        order.isPaid = charge(calculateTotal(order));
    }
}
// Models: данные без поведения
public class OrderModel {
    private List<OrderLineModel> lines = new ArrayList<>();
    private boolean paid;

    // геттеры и сеттеры на каждое поле
}

// Helpers: поведение заказа, вынесенное из заказа
public final class OrderHelper {
    public static BigDecimal calculateTotal(OrderModel order) {
        return order.getLines().stream()
                .map(l -> l.getPrice().multiply(BigDecimal.valueOf(l.getQuantity())))
                .reduce(BigDecimal.ZERO, BigDecimal::add);
    }
}

// Services: сценарий, которому приходится ходить и туда, и сюда
public class OrderService {
    public void confirm(OrderModel order) {
        order.setPaid(PaymentHelper.charge(OrderHelper.calculateTotal(order)));
    }
}
// Models: данные без поведения
data class OrderModel(
    val lines: MutableList<OrderLineModel> = mutableListOf(),
    var isPaid: Boolean = false,
)

// Helpers: поведение заказа, вынесенное из заказа
object OrderHelper {
    fun calculateTotal(order: OrderModel) = order.lines.sumOf { it.price * it.quantity }
}

// Services: сценарий, которому приходится ходить и туда, и сюда
class OrderService {
    fun confirm(order: OrderModel) {
        order.isPaid = PaymentHelper.charge(OrderHelper.calculateTotal(order))
    }
}

Заказ разрезан не по смыслу, а по типу файла: данные в Models, расчёт в Helpers, сценарий в Services. Добавить скидку – значит открыть все три, а сумму заказа теперь считает кто угодно, только не сам заказ (привет, Information Expert).

Чем это оборачивается:

  • одна задача размазана по трём папкам – правка «добавить поле в заказ» открывает четыре файла в разных местах дерева;
  • границы не держат – раз связи и так ходят через них, никто не мешает добавить ещё одну;
  • имена ни о чём не говорят – по OrderHelper не понять, что внутри;
  • свойства модуля не измерить – ни связность, ни зацепление у папки «прочее» не посчитать даже на глаз.

Разрушительное расцепление

Order IOrderLine ILineFactory ICostCalculator

Здесь границ, наоборот, слишком много. Кружки одного цвета – то есть части одного смыслового целого – разложены по отдельным рамкам, каждая со своим интерфейсом. Формально зацепление слабое: все зависят от абстракций. На деле связи никуда не делись, просто теперь каждая пересекает границу.

// Тот же заказ, но каждая часть – за своим интерфейсом
public class Order
{
    private readonly IOrderLineFactory _lineFactory;
    private readonly ICostCalculator _calculator;
    private readonly List<IOrderLine> _lines = new();

    public Order(IOrderLineFactory lineFactory, ICostCalculator calculator)
    {
        _lineFactory = lineFactory;
        _calculator = calculator;
    }

    public void AddLine(IProduct product, int quantity) =>
        _lines.Add(_lineFactory.Create(product, quantity));

    public decimal Total => _calculator.Calculate(_lines);
}
// Тот же заказ, но каждая часть – за своим интерфейсом
class Order {
public:
    Order(IOrderLineFactory& line_factory, ICostCalculator& calculator)
        : line_factory_(line_factory), calculator_(calculator) {}

    void AddLine(const IProduct& product, int quantity) {
        lines_.push_back(line_factory_.Create(product, quantity));
    }

    double Total() const { return calculator_.Calculate(lines_); }

private:
    IOrderLineFactory& line_factory_;
    ICostCalculator& calculator_;
    std::vector<std::unique_ptr<IOrderLine>> lines_;
};
// Тот же заказ, но каждая часть – за своим интерфейсом
type Order struct {
	lineFactory OrderLineFactory
	calculator  CostCalculator
	lines       []OrderLine
}

func NewOrder(lineFactory OrderLineFactory, calculator CostCalculator) *Order {
	return &Order{lineFactory: lineFactory, calculator: calculator}
}

func (o *Order) AddLine(product Product, quantity int) {
	o.lines = append(o.lines, o.lineFactory.Create(product, quantity))
}

func (o *Order) Total() float64 { return o.calculator.Calculate(o.lines) }
# Тот же заказ, но каждая часть – за своим интерфейсом
class Order:
    def __init__(self, line_factory: OrderLineFactory, calculator: CostCalculator) -> None:
        self._line_factory = line_factory
        self._calculator = calculator
        self._lines: list[OrderLine] = []

    def add_line(self, product: Product, quantity: int) -> None:
        self._lines.append(self._line_factory.create(product, quantity))

    @property
    def total(self) -> float:
        return self._calculator.calculate(self._lines)
// Тот же заказ, но каждая часть – за своим интерфейсом
class Order {
    private readonly lines: IOrderLine[] = [];

    constructor(
        private readonly lineFactory: IOrderLineFactory,
        private readonly calculator: ICostCalculator,
    ) {}

    addLine(product: IProduct, quantity: number): void {
        this.lines.push(this.lineFactory.create(product, quantity));
    }

    get total(): number {
        return this.calculator.calculate(this.lines);
    }
}
// Тот же заказ, но каждая часть – за своим интерфейсом
public class Order {
    private final OrderLineFactory lineFactory;
    private final CostCalculator calculator;
    private final List<OrderLine> lines = new ArrayList<>();

    public Order(OrderLineFactory lineFactory, CostCalculator calculator) {
        this.lineFactory = lineFactory;
        this.calculator = calculator;
    }

    public void addLine(Product product, int quantity) {
        lines.add(lineFactory.create(product, quantity));
    }

    public BigDecimal getTotal() {
        return calculator.calculate(lines);
    }
}
// Тот же заказ, но каждая часть – за своим интерфейсом
class Order(
    private val lineFactory: OrderLineFactory,
    private val calculator: CostCalculator,
) {
    private val lines = mutableListOf<OrderLine>()

    fun addLine(product: Product, quantity: Int) {
        lines += lineFactory.create(product, quantity)
    }

    val total: Double get() = calculator.calculate(lines)
}

Делает этот заказ ровно то же, что и заказ из последнего кейса, – но вместо new OrderLine(...) и Sum(...) здесь две зависимости, четыре интерфейса и ни одной второй реализации.

Чем это оборачивается:

  • чтобы понять одно действие, нужно открыть четыре файла – и в трёх из них не будет логики, только объявления;
  • абстракции ничего не дают – у каждого интерфейса ровно одна реализация, менять её никто не собирается;
  • правка расползается – добавили поле в позицию заказа, поправили интерфейс, фабрику и калькулятор;
  • тесты проверяют заглушки – в них подставлены моки, а не настоящее поведение.

Этот случай встречается реже остальных, зато обычно у тех, кто уже прочитал про Low Coupling.

Расцепление – не бесплатное. Каждый интерфейс, фабрика и слой – это ещё одна граница, которую читателю нужно пересечь, чтобы понять, что происходит. И в последнем листинге формально всё хорошо: зацепление слабое, зависимости приходят снаружи, каждый интерфейс подменяется в тестах. На деле у IOrderLineFactory и ICostCalculator по одной реализации, менять их никто не собирается, а заказ, позиция и товар – понятия, которые всегда меняются вместе, – разложены по разным файлам за абстракциями.

Вариант, к которому мы придём в следующем разделе, зацеплен сильнее: Order знает про OrderLine, а тот про Product. Но это связи внутри одного смыслового целого – и кода в нём втрое меньше. Разница между двумя листингами не в количестве зависимостей, а в том, совпадают ли границы со смыслом.

Интерфейс имеет смысл там, где реально ожидается вторая реализация или где граница проходит по чужой системе – СУБД, платёжный шлюз, почта. Интерфейс над классом, который никогда не подменят, добавляет границу, но не даёт свободы.

Low Coupling и High Cohesion

оформление заказа уведомления

А теперь тот же заказ с границами по смыслу. Всё, что относится к оформлению заказа, лежит в одной рамке и плотно связано внутри; уведомления – в другой. Наружу торчит одна связь: заказ просит отправить письмо и больше ничего про почту не знает.

// Заказ владеет позициями – он же и считает сумму
public class Order
{
    private readonly List<OrderLine> _lines = new();

    public decimal Total => _lines.Sum(l => l.Cost);

    public void AddLine(Product product, int quantity) =>
        _lines.Add(new OrderLine(product, quantity));

    public void MarkPaid() => IsPaid = true;

    public bool IsPaid { get; private set; }
}

// Наружу – одна связь: заказ просит уведомить, но не знает, чем и как
public class Checkout
{
    private readonly INotifier _notifier;

    public Checkout(INotifier notifier) => _notifier = notifier;

    public void Confirm(Order order)
    {
        order.MarkPaid();
        _notifier.OrderConfirmed(order);
    }
}
// Заказ владеет позициями – он же и считает сумму
class Order {
public:
    void AddLine(const Product& product, int quantity) {
        lines_.emplace_back(product, quantity);
    }

    double Total() const {
        double total = 0;
        for (const auto& line : lines_) total += line.Cost();

        return total;
    }

    void MarkPaid() { is_paid_ = true; }

private:
    std::vector<OrderLine> lines_;
    bool is_paid_ = false;
};

// Наружу – одна связь: заказ просит уведомить, но не знает, чем и как
class Checkout {
public:
    explicit Checkout(Notifier& notifier) : notifier_(notifier) {}

    void Confirm(Order& order) {
        order.MarkPaid();
        notifier_.OrderConfirmed(order);
    }

private:
    Notifier& notifier_;
};
// Заказ владеет позициями – он же и считает сумму
type Order struct {
	lines  []OrderLine
	isPaid bool
}

func (o *Order) AddLine(product Product, quantity int) {
	o.lines = append(o.lines, OrderLine{Product: product, Quantity: quantity})
}

func (o *Order) Total() float64 {
	total := 0.0
	for _, line := range o.lines {
		total += line.Cost()
	}

	return total
}

func (o *Order) MarkPaid() { o.isPaid = true }

// Наружу – одна связь: заказ просит уведомить, но не знает, чем и как
type Checkout struct {
	notifier Notifier
}

func (c Checkout) Confirm(order *Order) error {
	order.MarkPaid()

	return c.notifier.OrderConfirmed(order)
}
# Заказ владеет позициями – он же и считает сумму
class Order:
    def __init__(self) -> None:
        self._lines: list[OrderLine] = []
        self.is_paid = False

    def add_line(self, product: Product, quantity: int) -> None:
        self._lines.append(OrderLine(product, quantity))

    @property
    def total(self) -> float:
        return sum(line.cost for line in self._lines)

    def mark_paid(self) -> None:
        self.is_paid = True

# Наружу – одна связь: заказ просит уведомить, но не знает, чем и как
class Checkout:
    def __init__(self, notifier: Notifier) -> None:
        self._notifier = notifier

    def confirm(self, order: Order) -> None:
        order.mark_paid()
        self._notifier.order_confirmed(order)
// Заказ владеет позициями – он же и считает сумму
class Order {
    private readonly lines: OrderLine[] = [];
    isPaid = false;

    addLine(product: Product, quantity: number): void {
        this.lines.push(new OrderLine(product, quantity));
    }

    get total(): number {
        return this.lines.reduce((sum, line) => sum + line.cost, 0);
    }

    markPaid(): void {
        this.isPaid = true;
    }
}

// Наружу – одна связь: заказ просит уведомить, но не знает, чем и как
class Checkout {
    constructor(private readonly notifier: Notifier) {}

    confirm(order: Order): void {
        order.markPaid();
        this.notifier.orderConfirmed(order);
    }
}
// Заказ владеет позициями – он же и считает сумму
public class Order {
    private final List<OrderLine> lines = new ArrayList<>();
    private boolean paid;

    public void addLine(Product product, int quantity) {
        lines.add(new OrderLine(product, quantity));
    }

    public BigDecimal getTotal() {
        return lines.stream().map(OrderLine::getCost).reduce(BigDecimal.ZERO, BigDecimal::add);
    }

    public void markPaid() {
        paid = true;
    }
}

// Наружу – одна связь: заказ просит уведомить, но не знает, чем и как
public class Checkout {
    private final Notifier notifier;

    public Checkout(Notifier notifier) {
        this.notifier = notifier;
    }

    public void confirm(Order order) {
        order.markPaid();
        notifier.orderConfirmed(order);
    }
}
// Заказ владеет позициями – он же и считает сумму
class Order {
    private val lines = mutableListOf<OrderLine>()
    var isPaid = false
        private set

    fun addLine(product: Product, quantity: Int) {
        lines += OrderLine(product, quantity)
    }

    val total: Double get() = lines.sumOf { it.cost }

    fun markPaid() {
        isPaid = true
    }
}

// Наружу – одна связь: заказ просит уведомить, но не знает, чем и как
class Checkout(private val notifier: Notifier) {
    fun confirm(order: Order) {
        order.markPaid()
        notifier.orderConfirmed(order)
    }
}

Order, OrderLine и Product меняются вместе и лежат вместе – это связность. С почтой заказ общается через INotifier из одного метода – это слабое зацепление, и здесь интерфейс оправдан: за ним чужая система, которую действительно меняют (SMTP, мессенджер, пуш).

Так выглядит код, который дёшево менять: правка внутри одной рамки не выходит за её пределы, а чтобы разобраться в задаче, достаточно открыть одну папку. Ни один из трёх предыдущих вариантов не был «слишком связным» или «слишком расцепленным» сам по себе – все три просто провели границы не там.

А чем это отличается от SRP

SRP и высокая связность – про одно и то же, но с разной строгостью. SRP требует одной причины для изменения; связность допускает несколько обязанностей, если они про одно и то же и меняются вместе. Проще считать SRP частным, самым жёстким случаем высокой связности.

Как этим пользоваться

  • Цель – не ноль связей, а их правильное расположение. Слабое зацепление, а не полное; высокая связность, а не абсолютная.
  • Границы проводите по смыслу задачи, а не по технике. Папка «оформление заказа» лучше папки «сервисы», в которой лежат оформление заказа, рассылка и импорт прайсов.
  • Принцип работает на всех уровнях – метода, класса, модуля, папки и отдельного сервиса. Вопрос везде один: что меняется вместе, а что – по разным поводам.
  • Заметили много интерфейсов с одной реализацией – скорее всего, вы в левом нижнем углу.