DRY: у каждого знания в системе одно место. KISS: выбирайте самое простое решение, которое работает. YAGNI: не стройте под потребность, которой пока нет. Устранение дублирования вредит, когда два куска кода только выглядят одинаково, а меняются по разным причинам.
DRY про знание, а не про текст: ставка налога или правило пароля, скопированные в два файла, со временем разойдутся; два виджета Padding с одинаковым отступом нарушением не являются
Случайное дублирование: промокод и реферальный код сегодня оба из 8 символов; объедините их в один isValidCode, и следующее изменение правила для одного сломает другой
Правило трёх: вторую копию стоит потерпеть, а общее выносить на третьей, когда уже видно, что именно различается
KISS во Flutter: setState для переключателя на одном экране, ValueNotifier раньше Bloc, обычный класс раньше дженерика
YAGNI: никакой базы Repository<T> ради одной сущности, никакой системы плагинов ради одного способа оплаты; код «на потом» приходится читать, тестировать и мигрировать уже сейчас
Принципы тянут в разные стороны: DRY подталкивает к абстракции, KISS и YAGNI удерживают от неё; решайте по тому, сколько будет стоить изменение потом
FlutterMiddle
SOLID и принципы проектирования
Принципы SOLID в Dart и Flutter, DRY, KISS и YAGNI, связность и зацепление, закон Деметры, внедрение зависимостей и service locator, code smells и рефакторинг
Закрыто вопросов
0 / 18
- 0Повторить
- 4Часто спрашивают
Вопросы
Связанность (coupling) показывает, насколько модуль зависит от деталей другого; связность (cohesion) показывает, насколько тесно связано то, что лежит внутри одного модуля. Низкая связанность позволяет менять часть отдельно, а высокая связность собирает каждое изменение в одном месте.
Сильная связанность: виджет, который сам создаёт Dio() и парсит JSON, или Cubit, читающий поля другого Cubit; изменение API или того Cubit ломает экран
Слабая связанность: виджет получает состояние и колбэки, Cubit получает интерфейс репозитория через конструктор; каждая сторона может меняться за этой границей
Высокая связность: в lib/features/cart/ лежат модель корзины, репозиторий, Cubit и экраны, поэтому изменение корзины остаётся в этой папке
Низкая связность: CartService, который заодно форматирует даты и шлёт аналитику, или файл хелперов, который импортирует каждая фича
Быстрая проверка: посчитайте, сколько файлов затрагивает типичное изменение (много означает низкую связность) и сколько импортов нужно файлу (много означает сильную связанность)
Связанность никогда не равна нулю: кто-то должен знать все конкретные классы; держите это знание в composition root (main.dart)
Запах кода (code smell) означает внешний признак того, что с дизайном что-то не так: код работает, но менять его будет тяжело. Это повод присмотреться, а не баг и не обязательный рефакторинг.
Большой класс: State на 600 строк, который валидирует, ходит в сеть и раскладывает экран; обычно в нём несколько ответственностей
Зависть к чужим данным (feature envy): метод в основном читает поля другого объекта (order.items, order.discount, order.shipping), чтобы посчитать итог; перенесите его в Order
Стрельба дробью (shotgun surgery): один новый статус заказа правит enum, три switch, два маппера и таблицу цветов; знание размазано по коду
Расходящиеся изменения (divergent change): обратный случай, один класс правят по несвязанным причинам (формат API, тексты UI, правила цен)
Одержимость примитивами (primitive obsession): id, деньги и email как голые String, int и double, поэтому userId легко передать туда, где ждут orderId
Спекулятивная общность (speculative generality): базовые классы, параметры типа и хуки ровно с одним пользователем
Комментарий, объясняющий «что»: // can the user pay? над условием в десять строк просится стать геттером canPay
Рефакторинг меняет структуру кода, не меняя того, что код делает. Безопасным его делают тесты, фиксирующие текущее поведение, маленькие шаги, после каждого из которых приложение работает, и коммиты рефакторинга, отделённые от изменений фич.
Не переписывание и не исправление: если меняется то, что видит пользователь, это фича или багфикс; смешанные с рефакторингом, они делают дифф непроверяемым
Сначала тесты: прогоните их до начала; если тестов нет, напишите несколько, фиксирующих то, что код делает сейчас, включая странные места
Маленькие шаги: переименовать, вынести, перенести, встроить, и после каждого шага прогнать тесты; покрасневший шаг откатывают, а не отлаживают
Пусть правит инструмент: Rename, Extract Method, Extract Local Variable и Extract Widget из Dart analysis server, одинаково в VS Code и Android Studio, обновляют все места вызова; ручные правки что-нибудь пропускают
Один вид изменений на коммит: сначала перенос файлов, потом выделение, а изменение поведения отдельным PR
Правило бойскаута: оставляйте код чуть чище, чем нашли, но только вокруг того, что и так меняете
Разделите его по причинам изменений: правила валидации, вызов API, формат цен и вёрстка меняются в разное время по просьбе разных людей. Дайте каждой части своё место (state holder, репозиторий, форматтер, маленькие виджеты), чтобы State только связывал их с экраном.
Одна причина для изменений, а не один метод: спросите, кто попросит правку; продукт меняет правила, бэкенд-команда эндпоинт, дизайнеры вёрстку
Правила и сценарий отправки переезжают в ChangeNotifier, Cubit или Notifier, который юнит-тест гоняет без pumpWidget
Вызов API уходит за репозиторий, внедрённый в этот state holder; экран никогда не видит Dio и JSON
Форматирование уходит в маленький чистый класс или extension, чтобы корзина и чек показывали цены одинаково
Вёрстка делится на классы виджетов по секциям; приватные методы _buildX() укорачивают build, но оставляют все ответственности в одном классе
Быстрая проверка: сгруппируйте поля по методам, которые их используют; группы, которые никогда не пересекаются, и есть классы, ждущие выделения
Один размер ничего не доказывает: длинный виджет, который только раскладывает одну сложную форму, по-прежнему несёт одну ответственность
Открыт для расширения, закрыт для изменения: новый случай приходит новым кодом, подключённым к существующей точке расширения, а не очередной правкой кода, который уже работает. В виджетах это слоты и билдеры вместо флагов, в сервисах интерфейс, к которому добавляют реализации.
Flutter так устроен повсюду: ListTile принимает любой виджет в leading и trailing, AppBar принимает actions, ListView.builder принимает itemBuilder; никто не правит ListTile, чтобы добавить бейдж
Запах в виджете: общий ProductCard обрастает showBadge, isCompact и showFavorite, по флагу на экран; каждая просьба правит виджет, которым пользуются все экраны, и рискует им
Сервисы: оформление заказа принимает List<PaymentMethod>; Apple Pay добавляется новым классом и одной строкой в composition root, а код оформления не меняется
Другие точки расширения, которые стоит назвать: ThemeExtension, ScrollPhysics, TextInputFormatter, Interceptor в Dio
Запах в сервисе: один и тот же switch (method.type) скопирован в пять файлов, поэтому новый тип означает пять правок, и одну из них забудут
Sealed-классы закрыты намеренно: когда набор случаев ваш и каждый switch нужно пересмотреть, ошибка исчерпываемости и есть польза; если случаи добавляют другие, выбирайте интерфейс
Принцип подстановки Лисков (Liskov Substitution). LimitedCart принимают везде, где ждут Cart, но её add молча ничего не делает, когда корзина полна, поэтому buyAgain, написанный под Cart, считает товары, которых не добавил: возвращает 5, а count равен 3.
LSP касается поведения, а не сигнатур: у переопределения тот же тип, и анализатор доволен; изменилось обещание, на которое опирается каждый вызывающий Cart: после add значение count становится на единицу больше
Практическое правило: подтип может принимать больше и обещать больше, но не может требовать больше или выдавать меньше; молчаливый no-op или новый throw UnsupportedError выдают меньше, а дополнительный вызов, обязательный перед add, требует больше
Симптом: вызывающий код начинает писать if (cart is LimitedCart), чтобы защититься, и заодно нарушает Open/Closed
Исправление: внести различие в контракт: у любой Cart может быть лимит, а tryAdd сообщает, получилось ли, так что каждый вызывающий обрабатывает полную корзину; или уберите подкласс и передавайте лимит как политику
SDK тоже отступает от принципа, но открыто: List.unmodifiable возвращает List, чей add бросает UnsupportedError; это задокументировано, и поэтому код не должен менять список, который создал не он
Как поймать: прогоняйте тесты базового класса на каждом подтипе; этот упадёт на первом, который добавляет товар сверх лимита
Считайте фейк ещё одной реализацией, которая обязана соблюдать тот же контракт, и докажите это: опишите контракт один раз общим набором тестов и прогоняйте его и на фейке, и на настоящем репозитории.
Это принцип Лисков применительно к тестам: виджет-тест правдив ровно настолько, насколько правдив его фейк; фейк, который никогда не падает, не отдаёт пустую страницу или сортирует иначе, тестирует другое приложение
Контрактный тест: функция вроде ordersRepositoryContract(create) содержит тесты, а её вызывают и тестовый файл фейка, и тестовый файл настоящей реализации
Что входит в контракт: порядок, края пагинации, что означает «не найдено» (null или исключение, и какого типа), видна ли запись при следующем чтении, типы ошибок
Настоящая реализация гоняется против чего-то локального: MockClient, проигрывающий записанные ответы, эмулятор Firebase, Drift-база в памяти
Один свой фейк лучше пятидесяти стабов: InMemoryOrdersRepository, проверенный контрактом, заменяет разбросанные вызовы when(...), у каждого из которых своё представление о бэкенде
Когда контракт меняется: сначала напишите новый тест; обе реализации краснеют, пока не договорятся
Объект не может сменить свой класс, поэтому апгрейд означает замену User, и каждый экран, кэш и провайдер, держащий старый объект, продолжает показывать рекламу. Тариф является меняющимся состоянием, а не видом пользователя: сделайте его полем (user.plan) и спрашивайте о возможностях, а не о типах.
Устаревшие копии: всё, что хранит ссылку на старый объект User, показывает устаревшие данные, пока не перезагрузится; ничто не сообщает, что тип сменился
**Проверки is расползаются**: if (user is PremiumUser) в двадцати виджетах работает как switch по типу без проверки исчерпываемости; новый семейный тариф означает найти их все (Open/Closed)
Комбинации множатся: premium и admin, trial и premium; одиночное наследование не выражает обе роли сразу, а класс PremiumAdminUser уже тревожный знак
Моделируйте данными: final Plan plan, sealed-класс, варианты которого несут свои поля (Trial(endsAt)); апгрейд превращается в copyWith(plan: ...), state holder эмитит новое значение, и все слушатели перестраиваются
Спрашивайте о возможностях: user.can(Feature.noAds), тогда изменение тарифов правит одну таблицу, а не каждый экран
Когда подтип уместен: вид объекта не меняется за всю его жизнь, и каждый подтип соблюдает контракт базового класса, как PostItem и AdItem в ленте
Клиенты не должны зависеть от методов, которые не вызывают. В Dart implements SomeClass забирает всю публичную поверхность класса, и фейк для одного экрана обрастает двадцатью методами; дайте каждому клиенту маленький ролевой интерфейс, а один класс пусть реализует несколько.
Flutter устроен именно так: ListenableBuilder и AnimatedBuilder принимают любой Listenable (два метода), а не ChangeNotifier; vsync: принимает TickerProvider с единственным методом createTicker, поэтому туда можно передать State с SingleTickerProviderStateMixin
Чем обходится толстый интерфейс: когда история, оформление, возвраты и трекинг собраны в одном OrdersService, каждый фейк реализует всё это, а каждый новый метод задевает всех реализующих
Делите по клиентам, а не по сущностям: OrderHistory для экрана списка, OrderPlacement для оформления; настоящий репозиторий делает implements обоих, а каждая view model просит тот, которым пользуется
Ловушка при реализации конкретного класса: его приватные члены нельзя реализовать из другой библиотеки, поэтому если его собственная библиотека вызовет такой член на вашем объекте, Dart бросит NoSuchMethodError в рантайме; объявляйте abstract interface class
Не дробите до атомов: интерфейс на каждый метод читается так же плохо, как один толстый; делите там, где клиенты действительно различаются
Инверсия зависимостей описывает правило дизайна: высокоуровневый код владеет абстракцией, а низкоуровневый её реализует, поэтому зависимость направлена к бизнес-логике. Внедрение зависимостей и сервис-локатор дают два способа передать реализацию: её вталкивают снаружи или класс вытягивает её сам.
Инверсия касается владения: OrdersRepository объявлен рядом с кодом, который им пользуется (в домене или фиче), поэтому слой данных импортирует домен, а не наоборот
Внедрение без инверсии встречается часто: view model, получающая DioOrdersApi, внедрена и тестируема, но всё равно повторяет форму API Dio
Контейнер не обязателен: параметры конструктора, связанные в main.dart, дают и то и другое; get_it, injectable и Riverpod только автоматизируют связывание
Сервис-локатор сохраняет абстракцию, но прячет её: GetIt.I<OrdersRepository>() внутри класса не видно ни в одной сигнатуре, а забытая регистрация падает только в рантайме; обращайтесь к локатору на краях (composition root, виджет) и внедряйте оттуда
Inversion of Control служит общим зонтиком: фреймворк вызывает ваш код; Flutter вызывает ваши build и initState, а DI применяет ту же идею к созданию объектов
Пишите интерфейс там, где он обозначает границу, которая должна оставаться чистой или скорее всего изменится: SDK вендора, бэкенд, разделение по платформам, домен, которому нельзя импортировать слой данных. Внутри фичи хватает конкретного класса: тест и так может его реализовать или замокать.
Окупается для изменчивых зависимостей: платежи, аналитика, карты, бэкенд, который вы не контролируете; интерфейс становится швом, где появится вторая реализация
Окупается на границах слоёв и пакетов: домен объявляет OrdersRepository, а пакет данных его реализует; без интерфейса зависимость смотрит в обратную сторону
Окупается для реальных вариантов: мобайл и веб, демо-сборка, карта и Apple Pay
Церемония: ProfileService плюс ProfileServiceImpl с теми же членами и без второй реализации; два файла на каждое изменение, а переход к определению попадает в интерфейс
Тестам это не нужно: class FakeClock implements Clock работает с конкретным Clock, как и мок из mocktail class MockClock extends Mock implements Clock
Тоже церемония: отдельный use case на каждый метод репозитория, который только пробрасывает вызов
На границе пакета решайте сразу: новый член в классе, который реализуют другие, ломает их, поэтому либо пометьте класс final (реализовать его не сможет никто), либо опубликуйте abstract interface class и считайте каждый новый член ломающим изменением
Общайтесь только со своими непосредственными соседями и говорите им, что сделать, вместо того чтобы вытаскивать их данные и решать за них. Эта цепочка проходит через три объекта в голый список, поэтому кнопка зависит от того, как устроена сессия, и обходит все правила, которые должна соблюдать корзина.
Чем это обходится: кнопка теперь зависит от Session, User, Cart и List; перенос корзины из User ломает каждый экран, который скопировал цепочку
Правила утекают в вызывающий код: лимит товаров, итоговую сумму и событие аналитики заново реализует каждая кнопка, и одна из них что-нибудь забудет
Говори, а не спрашивай: context.read<CartController>().add(item) соблюдает правила и сообщает, что произошло; экран только реагирует
Признак в коде: ! в середине цепочки или проверка на null на каждом звене
Не каждая точка является нарушением: Theme.of(context).textTheme.bodyLarge проходит по неизменяемым данным, и это нормально; закон касается команд объектам, у которых есть поведение и инварианты
Каскады тоже в порядке: Paint()..color = c..strokeWidth = 2 продолжает говорить с тем же объектом
Prefs работает только после init(), а вызывает его лишь HomePage. Уведомление открывает экран настроек напрямую, HomePage так и не строится, и первое чтение late-поля бросает исключение. Сделайте недостроенный Prefs невозможным: загружайте его в main и передавайте вниз готовый объект.
Временная связанность (temporal coupling): корректность зависит от порядка вызовов, который ничто не гарантирует, и каждая новая точка входа (диплинк, виджет-тест, фоновый изолят) даёт ещё один шанс пропустить init()
**К тому же без await**: даже через HomePage вызов prefs.init() запускается и забывается, поэтому чтение в первом кадре всё равно может случиться раньше, чем хранилище готово
Исправление: приватный конструктор, принимающий готовый SharedPreferences, и static Future<Prefs> load(); main дожидается его до runApp, так что никакой код не получит неготовый Prefs
**late даёт обещание, которое компилятор не проверит**: здесь он превращает отсутствующую зависимость в падение на одном из путей; final-поле, заданное в конструкторе, делает её требованием на этапе компиляции
Уберите и глобальную переменную: отдавайте экземпляр через Provider или ваш DI, тогда тест соберёт свой после SharedPreferences.setMockInitialValues({})
Как поймать в следующий раз: виджет-тест, который поднимает SettingsPage отдельно, без HomePage перед ним
Длинный список параметров, группа данных (data clump), одержимость примитивами и флаговые аргументы. Дайте id собственные типы, соберите адрес и цену в объекты-значения, замените два позиционных bool на enum и именованный флаг и передавайте один OrderRequest.
Длинный список параметров: десять позиционных аргументов; если перепутать user.id и product.id, код скомпилируется, а true, false в месте вызова ничего не говорит. Введите объект-параметр: OrderRequest
Группа данных: street, city и zip всегда ходят вместе; это Address, который потом сможет валидировать себя сам
Одержимость примитивами: деньги в double накапливают погрешность (0.1 + 0.2 не равно 0.3), а цену и валюту можно передать по отдельности; объект-значение Money(cents, currency) решает обе проблемы
**Id как String**: extension types (extension type const UserId(String value) {}) ничего не стоят в рантайме, а передать ProductId туда, где ждут UserId, больше не получится: код не скомпилируется
Флаговые аргументы: isGift и express переключают поведение внутри функции; enum (Shipping.express) или именованные параметры делают вызов читаемым, а линт avoid_positional_boolean_parameters подсвечивает позиционные
Одних именованных параметров мало: вызов становится читаемым, но остаются десять разрозненных значений и ни одного типа, который их объединяет
У него нет связности: даты, цены, валидаторы и хелперы диалогов лежат вместе лишь потому, что им не нашлось места, и теперь каждая фича зависит от всех сразу. Перенесите каждую функцию к коду, которому принадлежит её знание, часто как extension или метод на её типе.
Скрытая связанность: изменение formatPrice ради корзины становится изменением файла, который импортируют сорок фич, поэтому ревью, тесты и конфликты слияния расходятся веером
Ничего не найти: искать не по чему, и коллеги пишут formatPrice2 вместо того, чтобы найти первую
Функции про один тип переезжают в него: formatDuration(d) становится extension на Duration, isValidEmail(s) становится Email.tryParse(s); поведение оказывается рядом со своими данными и появляется в автодополнении
Хелперы одной фичи переезжают в фичу: если им пользуется только оформление заказа, его место в features/checkout/
У действительно общего кода появляется тема: core/money/, core/time/relative_time.dart; маленькие файлы, которым можно дать имя
Переносите маленькими шагами: одна группа хелперов на PR, перенос средствами IDE, чтобы импорты обновились, и удаление utils.dart, когда он опустеет
Абстракцию построили по тому, как выглядели первые репозитории, а не по тому, что нужно их клиентам, и каждую сущность, которая не вписалась, загнали в CRUD-контракт, который она не может выполнить. Встройте базу обратно в конкретные репозитории, а потом выносите только то, что у них действительно общее.
Почему так вышло
Случайное сходство: первые три сущности были CRUD поверх REST, и базовый класс выглядел как DRY; потом появились справочник стран только для чтения и лента с пагинацией по курсору
Каждое несовпадение протекает: переопределение, которое бросает исключение, нарушает принцип Лисков (любой вызывающий, держащий BaseRepository<T>, может упасть), метод, который никто не вызывает, нарушает Interface Segregation, а параметр, добавленный «для одного случая», делает базу общей проблемой
Невозвратные затраты держат её на плаву: общий класс гнут, потому что он уже есть; по словам Санди Мец, «дублирование гораздо дешевле неправильной абстракции»
Как выбраться
Сначала встройте: перенесите в каждый репозиторий то, чем он пользуется, остальное удалите; клиенты, зависевшие от базового типа, получают вместо него интерфейс фичи
Выносите заново через композицию: действительно общее обычно оказывается инфраструктурой (API-клиент, который маппит ошибки, хелпер пагинации, кэш) и хранится в поле, а не наследуется
Контракты по форме клиентов: CountriesRepository.all(), OrdersRepository.recent() и place(), названные по намерениям
В следующий раз
Правило трёх и одна причина для изменений: дождитесь третьей копии и проверьте, что копии менялись бы вместе
От хелпера отказаться проще, чем от базового класса: один клиент может перестать вызывать функцию, не задевая остальных
Открывайте наружу минимальную поверхность, которая покрывает сценарии приложений, и прячьте каждое решение, которое может измениться: SDK вендора, HTTP-клиент, хранилище. Один файл библиотеки экспортирует публичные типы, всё остальное лежит в lib/src, и ни один сторонний тип не появляется в публичной сигнатуре.
Что открывать
Сценарии, а не механику: Payments.charge(amount, idempotencyKey: ...), возвращающий sealed ChargeResult, а не клиент вендора и не Response из Dio
Только свои типы: сторонний тип в публичной сигнатуре превращает его мажорный релиз в ваш мажорный релиз, а приложения начинают зависеть от него напрямую
**Явный список export ... show: новый тип в экспортируемом файле остаётся приватным, пока кто-то не добавит его в список
Что закрыть
Модификаторы классов**: final class для объектов-значений вроде Money, abstract interface class для того, что приложения реализуют в тестах; класс без модификатора можно и наследовать, и реализовать, поэтому даже новый метод в нём может сломать приложение
**@internal и @visibleForTesting** из package:meta: @internal помечает члены, которые нужны файлам самого пакета, @visibleForTesting те, что открыты только ради тестов; приложение, которое использует любой из них, получает предупреждение анализатора, а не ошибку компиляции
Sealed-результаты накладывают обязательство: каждый новый подтип ломает исчерпывающие switch в приложениях, а значит требует мажорного релиза; выпускайте известные случаи плюс общий вариант ошибки для остальных
Как сохранить свободу изменений
Приложения импортируют только barrel-файл: линт implementation_imports подсвечивает package:payments/src/...
Сначала пометьте, потом удаляйте: держите пробрасывающий член с @Deprecated в течение одной мажорной версии
Тестируйте как потребитель: пишите большую часть тестов пакета только против package:payments/payments.dart, тогда рефакторинг src/ их не покрасит