open atlas
↑ К треку
Паттерны и качество кода CP · 07 · 03

Проведение границ модулей

Максимизируй связность внутри модуля, минимизируй связанность между. Входящая и исходящая связанность, принцип устойчивых зависимостей: завись в сторону устойчивости. Разбивай по фичам, а не по слоям — разбиение по слоям размазывает изменение одной фичи по всем папкам.

CP Senior ◷ 20 min
Уровень
ОсновыJuniorMiddleSenior

Две папки, один и тот же код, противоположная стоимость. В одном репозитории добавление фичи «список желаний» затрагивает один каталог — его обработчик, его правило, его запрос, его типы лежат вместе — и ты выкатываешь её, не открывая больше ничего. В другом та же фича означает новый файл в controllers/, ещё один в services/, ещё один в repositories/, ещё один в dto/, четыре импорта, сшитых через четыре пакета. Оба выглядят организованно. Только один таков на деле.

Разница в том, где проходят границы. Граница — это не папка для порядка, это ставка на то, что меняется вместе. Проведи её вокруг того, что меняется по одной причине, — и получишь маленький устойчивый шов, который ты редко пересекаешь. Проведи её вокруг технического вида — все контроллеры здесь, все сервисы там — и ты разрезал прямо поперёк каждой фичи, так что любое изменение фичи протекает через них все. Этот урок — про намеренное проведение разреза: высокая связность внутри, низкая связанность снаружи, с зависимостями, направленными в правильную сторону.

Цель

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

1

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

// payments/index.ts — граница. Вызывающие видят только это.
export interface PaymentGateway {
  charge(amountCents: number, token: string): Promise<ChargeResult>;
}
export type ChargeResult = { ok: true; id: string } | { ok: false; reason: string };

За этим швом StripeGateway может стать AdyenGateway, могут появиться ретраи и ключи идемпотентности, а код оформления заказа никогда не изменится — он всегда знал только charge. Интерфейс маленький (один метод) и устойчивый (он описывает что, а это редко двигается), тогда как реализация большая и изменчивая (она описывает как, а это двигается постоянно). Эта асимметрия — маленькая устойчивая поверхность, большое изменчивое тело — и есть подпись границы, ради которой стоит её проводить.

2

Связность — сила, решающая, что живёт внутри границы: что меняется вместе, то и принадлежит вместе. Модуль связен, когда его части служат одной цели и меняются по одной причине. Операционный тест — тот самый из призмы стоимости изменения: когда движется одно требование, сколько модулей ты открываешь? Один — значит связность совпала с изменением. Много — значит граница была проведена поперёк изменения, а не вокруг него.

// ВЫСОКАЯ связность: всё, что нужно «оформлению заказа», в одном месте
checkout/
  checkout.handler.ts    // HTTP вход/выход для checkout
  pricing.ts             // правило ценообразования checkout
  checkout.repo.ts       // персистентность checkout
  checkout.types.ts      // формы, общие только внутри checkout

Добавление купона в checkout — это правки в одном каталоге. Части связаны друг с другом — но это нормально: они всегда собирались меняться вместе, поэтому внутренняя связанность — это просто модуль, делающий свою работу. Ошибка — путать эту безвредную внутримодульную связанность с вредной, межмодульной.

3

У связанности есть направление, и направление решает всё: входящая против исходящей. Входящая связанность (Ca) считает, кто зависит от модуля, — его входящие стрелки, его fan-in. Исходящая связанность (Ce) считает, от кого зависит модуль, — его исходящие стрелки, его fan-out. Их отношение даёт нестабильность: I = Ce / (Ca + Ce), от 0 (я ни от кого не завишу; все зависят от меня — максимально устойчив) до 1 (я завишу от всего; от меня не зависит никто — максимально нестабилен).

// Листовая деталь: высокая Ce, низкая Ca → I ≈ 1 (нестабилен, свободен меняться)
//   StripeGateway зависит от Stripe SDK, конфига, логирования, интерфейса PaymentGateway
//   …и (почти) ничто не зависит от StripeGateway напрямую.

// Ядровая абстракция: низкая Ce, высокая Ca → I ≈ 0 (устойчив, дорого менять)
//   PaymentGateway не зависит ни от чего; checkout, возвраты, выставление счетов — все зависят от него.

Устойчивость здесь — это не «хороший код», это утверждение о радиусе поражения. Модуль, от которого зависит многое (высокая Ca, низкая I), дорого менять, потому что изменение расходится наружу, поэтому ему лучше быть абстракцией, которой это редко нужно. Модуль, от которого не зависит ничто (низкая Ca, высокая I), дёшево менять, потому что вниз по течению ничего не ломается, — и именно туда ты хочешь поместить изменчивую деталь.

4

Принцип устойчивых зависимостей: завись в направлении возрастающей устойчивости. Стрелки должны указывать от нестабильного к устойчивому — I должна убывать вдоль каждой стрелки зависимости. Изменчивая деталь зависит от устойчивой абстракции; никогда наоборот. Причина механична: если бы устойчивый модуль зависел от изменчивого, каждое подёргивание изменчивой детали вынуждало бы менять то, от чего зависят все остальные, и рябь разорвала бы систему. Указать стрелку в другую сторону — значит запереть изменчивость в листьях, где её радиус поражения равен нулю.

// ПРАВИЛЬНО: деталь → абстракция. Нестабильность убывает вдоль стрелки.
//   checkout (I≈0.6)  →  PaymentGateway (I≈0.0)  ←  StripeGateway (I≈1.0)
//   И политика, и деталь зависят от устойчивого интерфейса посередине.

// НЕПРАВИЛЬНО: абстракция → деталь. checkout импортирует StripeGateway напрямую.
//   import { StripeGateway } from "../payments/stripe";   // устойчивый вызывающий теперь обвенчан с изменчивой деталью

Правая форма — это просто инверсия зависимостей, сформулированная как закон границы: изменчивый StripeGateway и изменчивая политика checkout оба указывают внутрь, на устойчивый PaymentGateway. Когда появляется режим отказа — устойчивый модуль тянется к конкретному, быстро меняющемуся, — ты посадил зависимость, которая бежит в гору против устойчивости, и она будет ломаться куда сильнее, чем должна.

5

Разбивай по фичам, а не по слоям — и распознавай разбиение по слоям как высокую связанность, переодетую в организацию. Разбиение по слоям группирует файлы по техническому виду: controllers/, services/, repositories/. Оно выглядит упорядоченным, потому что все файлы одного вида вместе. Но требования приходят не по слою — они приходят по фиче. «Добавить подарочные карты» — это один запрос на изменение, который при разбиении по слоям вынуждает править controllers/, services/, repositories/ и dto/ одновременно: дробящая хирургия, каждый раз. Папки связны по неправильной оси (вид), а это значит, что они связаны вдоль оси, которая реально варьируется (фича).

разбиение по слоям  (низкая связность на фичу)    разбиение по фичам (высокая связность на фичу)
  controllers/  checkout.ctrl, refund.ctrl          checkout/   handler, pricing, repo, types
  services/     checkout.svc, refund.svc            refunds/    handler, policy, repo, types
  repositories/ checkout.repo, refund.repo          payments/   gateway (граница), stripe, adyen
  одна фича = правка в каждой папке                 одна фича = одна папка

Разбиение по фичам — не вопрос стиля; оно выравнивает границу с осью изменения, так что частое изменение локально, а межмодульная поверхность сжимается до намеренных устойчивых швов (вроде payments/). Когда не применять его: подлинно сквозная забота, у которой нет дома-фичи — middleware авторизации, адаптер логирования, общие типы-значения, — действительно принадлежит общему/платформенному модулю. Запихивание их в папку-фичу просто переселяет связанность. Сначала фичи, с маленьким честным «общим ядром» для того, что по-настоящему охватывает несколько фич.

Разбор примера

Один и тот же код, два разреза границы. Начни с разбиения по слоям — приятного на вид, враждебного к изменению:

// controllers/checkout.controller.ts
import { CheckoutService } from "../services/checkout.service";
// services/checkout.service.ts
import { CheckoutRepository } from "../repositories/checkout.repository";
import { StripeGateway } from "../gateways/stripe.gateway";   // сервис обвенчан с конкретной деталью
// repositories/checkout.repository.ts
// dto/checkout.dto.ts

Теперь добавь купон в checkout. Ты правишь контроллер (новое поле), сервис (применить скидку), репозиторий (сохранить код купона) и DTO (провалидировать его) — четыре папки, четыре файла, четыре шанса забыть один. И checkout.service импортирует StripeGateway напрямую: относительно устойчивая политика зависит от изменчивой детали (I идёт вверх вдоль стрелки — режим отказа из шага 4).

Перережь по фичам, с gateway как устойчивой границей:

// checkout/checkout.handler.ts   — HTTP-край
// checkout/pricing.ts            — купоны + скидки живут здесь
// checkout/checkout.repo.ts      — персистентность
// checkout/checkout.types.ts     — формы, используемые только внутри checkout
import type { PaymentGateway } from "../payments";   // завись от абстракции, а не от Stripe

// payments/index.ts              — единственный устойчивый шов, который пересекает checkout
export interface PaymentGateway { charge(amountCents: number, token: string): Promise<ChargeResult>; }

Изменение купона теперь — одна папка. Checkout зависит от интерфейса PaymentGateway (I≈0), поэтому замена Stripe на Adyen никогда не трогает checkout. Связность выросла (части фичи лежат вместе), межмодульная связанность упала (один узкий шов вместо четырёх размытых), и каждая зависимость бежит в сторону устойчивости. Ничто не стало хитрее — разрез просто перестал пересекать ось изменения.

Почему это работает

Почему нестабильность — не дефект, который надо «починить»? Потому что здоровой системе нужны нестабильные модули — это места, где изменение и должно происходить. Ты хочешь StripeGateway при I≈1: его должно быть тривиально переписать, и высокая нестабильность как раз и делает это дёшево. Принцип не «сделать всё устойчивым», а «расположить стрелки так, чтобы изменчивость жила в модулях с низкой входящей связанностью (вниз по течению нечего ломать), а устойчивость жила в абстракциях, на которые всё указывает». Парная идея Роберта Мартина — принцип устойчивых абстракций — добавляет вторую половину: модуль, который устойчив (тяжело менять), должен быть ещё и абстрактным (интерфейсом), чтобы он мог быть устойчивым, не будучи жёстким. Устойчивый-и-конкретный — это по-настоящему мучительный квадрант: все от него зависят, а расширить его без правки нельзя.

Частая ошибка

Соблазнительная ошибка — читать «низкую связанность» как «нулевую связанность» и переразбивать. Разорвать одну связную фичу на пять микро-модулей, чтобы загнать связанность к нулю, — значит просто превратить внутренние вызовы в межграничные импорты: ты добавил входящих/исходящих стрелок, а не убрал, и теперь одно изменение фичи пересекает пять швов вместо того, чтобы остаться в одном. Связанность между тем, что меняется вместе, — не враг; враг — связанность между тем, что меняется по разным причинам. Точно так же не насаждай разбиение по фичам как культ на по-настоящему общую инфраструктуру (авторизация, логирование, денежные типы): папка-«фича» для кода без фичи — это просто папка-слой с дружелюбным именем. Режь вокруг оси изменения, а не вокруг лозунга.

Проверь себя
Викторина

Модуль политики checkout сейчас импортирует конкретный StripeGateway напрямую. По принципу устойчивых зависимостей и входящей/исходящей связанности — почему это неправильное направление и в чём исправление?

Итог

Граница модуля — это ставка на то, что меняется вместе: максимизируй связность внутри (части, меняющиеся по одной причине, живут вместе) и минимизируй связанность снаружи (немного узких намеренных швов). У связанности есть направление — входящая (кто зависит от меня) против исходящей (от кого завишу я) — и их отношение даёт нестабильность, меру радиуса поражения. Принцип устойчивых зависимостей говорит, что стрелки должны бежать в сторону устойчивости: изменчивая деталь зависит от устойчивой абстракции, никогда наоборот, так что текучка остаётся запертой в листьях с низкой входящей связанностью. Практический рычаг — разбивай по фичам, а не по слоям: разбиение по слоям выглядит организованным, но режет поперёк каждой фичи, превращая один запрос на изменение в дробящую хирургию — высокая связанность, маскирующаяся под порядок. Режь вокруг оси изменения, направляй зависимости внутрь, на устойчивые интерфейсы, и держи маленькое честное общее ядро для того, что по-настоящему охватывает несколько фич.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 4 завершено

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.