Четыре способа отделить UI от логики. Различаются тем, кто кого вызывает; реактивные перестроения Flutter делают естественными MVVM и MVI.
MVC: контроллер между моделью и вью; самый размытый из четырёх, и почти всякий современный «MVC» на деле MVP или MVVM
MVP: вся логика в presenter-е, он дёргает методы глупого вью (showLoading()); привычка из Android-Java, во Flutter редкость
MVVM: view model выставляет наблюдаемое состояние, вью перестраивается по нему; это ChangeNotifier, Notifier в Riverpod, Cubit
MVI: один неизменяемый объект состояния на экран и поток в одну сторону: intent → reducer → state → view; это и есть event/state-модель BLoC
Соответствие Flutter: дерево виджетов во всех четырёх паттернах и есть вью; виджет сам не меняет состояние, он отправляет intent (вызов метода или событие) и перестраивается по новому состоянию
FlutterMiddle
Архитектурные паттерны
MVC, MVP, MVVM, Clean Architecture, паттерн Repository
Закрыто вопросов
0 / 22
- 0Повторить
- 7Часто спрашивают
Вопросы
Clean Architecture делит приложение на три слоя с одним правилом: зависимости направлены только внутрь. Внутренние слои ничего не знают о внешних.
Domain (внутри): сущности, use case-ы и интерфейсы репозиториев на чистом Dart, без импорта Flutter
Data: реализует эти интерфейсы: источники API и базы, JSON-модели, мапперы в сущности
Presentation (снаружи): Bloc/Cubit/Notifier и виджеты, которые ходят к данным только через use case-ы
Зачем правило: бизнес-логика компилируется без Flutter, поэтому замена HTTP-клиента или UI трогает один слой
Частая ошибка: fromJson или Color в доменной сущности: стрелка зависимости развернулась наружу
Гайд на docs.flutter.dev рекомендует MVVM в два слоя: UI-слой из view и view model и слой данных из репозиториев и сервисов. Доменный слой с use case необязателен: его добавляют, только когда логика сложная, переиспользуемая или объединяет несколько репозиториев.
View: виджеты почти без логики; одна view model на экран
View model: ChangeNotifier, который превращает данные репозитория в состояние UI и выставляет действия пользователя как команды (небольшие объекты, отслеживающие выполнение, ошибку и результат)
Репозиторий: единый источник правды для одного вида данных: кэш, повторы, маппинг в доменные модели; репозитории никогда не зависят друг от друга
Сервис: stateless-обёртка над одним источником данных: REST-клиент, платформенный плагин, локальные файлы
Ещё рекомендуется: однонаправленный поток данных, иммутабельные модели, DI через provider, абстрактные репозитории, чтобы dev- и prod-сборки подменяли реализации; в её кейс-стади также используется тип Result вместо исключений
В сравнении с Clean Architecture: доменный слой необязателен, а не обязателен, и view model может обращаться к репозиторию напрямую. Зависимости идут сверху вниз: view model -> (use case) -> репозиторий -> сервис, то есть UI зависит от слоя данных; Clean разворачивает это, держа интерфейсы репозиториев в домене, так что от него зависят данные. Гайд просит меньше классов и не требует такого разворота.
SOLID объединяет пять правил, чтобы классы оставались маленькими и заменяемыми. Во Flutter больше всего окупаются Single Responsibility и Dependency Inversion; остальные три работают в основном на границах пакетов.
S (одна задача на класс): виджет рисует, репозиторий достаёт данные; если в описании класса нужно слово «и», разделяйте
O (открыт для расширения): добавляйте новую реализацию интерфейса (новый PaymentMethod), а не правьте код оформления заказа, который переключается по типу; sealed-семейства представляют обратный компромисс: они закрыты намеренно, чтобы компилятор находил каждый switch
L и I (честные и узкие интерфейсы): фейк должен работать везде, где работает настоящий, а вызывающий не должен зависеть от методов, которыми не пользуется
D (зависимость от абстракций): Bloc принимает AuthRepository, а не DioAuthRepository, и получает его через конструктор
Не перебарщивайте: интерфейс с одной реализацией и без тестового дублёра оказывается лишним кодом, а не дизайном
В маленьком или CRUD-приложении: если вы не можете назвать изменение, которое изолировал бы слой, этот слой превращается в чистые расходы, а там это почти весь код.
Один CRUD-экран при полной слоистости превращается в страницу, state holder, use case, интерфейс репозитория, его реализацию, источник данных, DTO, сущность и маппер. Если добавили одно поле, правите почти всё это.
Можно не делать: MVP, внутренний инструмент, тонкие экраны поверх API, один разработчик и один бэкенд
Оставьте в любом случае: границу репозитория на фичу: именно этот шов дорого добавлять потом
Оставьте в любом случае: неизменяемое состояние, один state holder на экран и никакого BuildContext ниже слоя виджетов
Пора добавлять слои, когда: те же данные пошли из двух источников или одно правило продублировано на двух экранах
Держите два класса: UserDto в слое данных, который знает про JSON, и обычную доменную сущность User, которая про JSON не знает. Между ними стоит маппер (extension-метод или маленькая функция).
fromJson прямо на сущности привязывает бизнес-правила к формату бэкенда, и любое изменение JSON расходится по домену и UI.
Что это даёт: API переименовал поле, а меняются только DTO и маппер
Свобода формы: выкиньте поля, не нужные бизнесу, добавьте вычисляемые, соберите сущность из двух DTO
Не наследуйте: DTO, унаследованный от сущности чтобы не писать лишнего, протаскивает слой данных в домен; используйте маппер
Когда можно пропустить: прототип или приложение из одного экрана, где API уже совпадает с доменом
Не придирка: один этот импорт разворачивает стрелку зависимостей. Слой, от которого зависят все остальные, теперь компилируется только там, где есть Flutter (ни на сервере, ни в CLI, ни под обычным dart test).
**Color означает решение про отрисовку**: зашитое в бизнес-объект; следом появится IconData, потом переведённая строка, и границы больше нет
Моделируйте смысл: enum OrderStatus { pending, paid, shipped, cancelled } в домене; цвет, иконку и формулировку выбирает UI, обычно расширением над этим enum
То же про тексты: AppLocalizations внутри use case повторяет ту же ошибку в другом обличье
Пусть проверяет инструмент: вынесите домен в отдельный пакет без зависимости от flutter, и анализатор отметит такой импорт (depend_on_referenced_packages), а dart test упадёт
Быстрая проверка: если доменные тесты требуют flutter test, а не dart test, стрелка уже смотрит не туда
Это HTTP-клиент, надевший имя репозитория. Форму репозиторию задаёт то, что нужно вызывающим (доменные типы на входе и выходе), а не то, что случайно выставил бэкенд.
Только доменные типы: Response или Map<String, dynamic> через границу означает, что парсить будет каждый вызывающий
Имена по намерению: byId, watch, changeAvatar, а не getUserV2 и postAvatarUpload
Один репозиторий на агрегат: User, Order, Cart; не по эндпоинту и не по экрану
Оставьте себе свободу: попадание в кэш, два параллельных запроса или другой бэкенд не должны быть видны в сигнатуре
Быстрая проверка: если переименование роута на бэкенде заставляет править доменный файл или UI, границы ещё нет
Это анемичная доменная модель: сущность хранит только данные, поэтому правила, определяющие, что такое заказ, копируются в каждый state holder, который с ним работает.
Дубли расходятся: на экране деталей отмена в течение 24 часов, а в списке в течение 48, и никто этого не замечает
Правила дорого тестировать: чтобы проверить один boolean, нужны Cubit, мок репозитория и эмит состояния
Существуют невалидные объекты: можно собрать Order со статусом paid и paidAt равным null, и каждый читатель дописывает защитные проверки
Где чьё место: инварианты в конструкторе или фабрике; правила про одну сущность оформляйте методами на ней (order.canCancelAt(now)); правила, которым нужен другой агрегат или I/O, выносите в use case
Не перестарайтесь: сущность, которая сама ходит в сеть и сохраняется, хуже анемичной; и передавайте now внутрь, а не читайте часы внутри правила
Три независимых поля (isLoading, error, items) дают восемь комбинаций, а законных у экрана всего четыре. Виджет честно рисует состояние, которого не должно было существовать.
Возьмите вместо флагов одно sealed-семейство на экран: Loading, Loaded(items), Empty, Failed(error). switch по sealed-типу исчерпывающий, поэтому необработанный случай даёт ошибку компиляции, а не скриншот от QA.
Данные лежат в том варианте, где они есть: больше никаких state.items! и вопросов «а тут может быть null?»
Реальные сочетания моделируются осознанно: «загружено и обновляется» описывается полем у Loaded, и список со спиннером уживаются только потому, что вы так решили
От библиотеки не зависит: Cubit, Notifier и ValueNotifier одинаково держат sealed-состояние; AsyncValue в Riverpod даёт этот же паттерн из коробки
Разовые вещи не входят в состояние: снекбар, оставленный в объекте состояния, повторится на следующем ребилде
Он делает работу трёх слоёв сразу: сам создаёт Dio, парсит JSON и держит BuildContext, чтобы показывать UI. Внедрите репозиторий, выдавайте каждый исход как состояние, а снекбар пусть показывает виджет в ответ на состояние.
**BuildContext в Cubit**: Cubit может пережить экран, и к моменту, когда load() продолжится, контекст уже деактивирован; к тому же такой Cubit не протестировать без дерева виджетов
**Dio() прямо в методе: тест не может подставить фейк, а базовый URL, auth-интерсептор и пул соединений общего клиента обходятся стороной
JSON в presentation: формат API протекает в state holder; парсингу место в DTO и маппере слоя данных
Нет пути для ошибки**: упавший запрос вылетает исключением из load(), состояние остаётся ProfileLoading, и спиннер крутится вечно
Снекбар является одноразовым эффектом, а не работой Cubit: выставьте факт (needsEmail) в состоянии и реагируйте в BlocListener, который срабатывает на смену состояния и никогда не во время build
ViewModel живёт в том, что владеет ею в дереве виджетов: State или элемент провайдера выше экранов, которые её используют; создаётся один раз и диспоузится вместе с владельцем. Созданная внутри build, она становится новым объектом на каждый ребилд: load() вызывается заново, а старая утекает.
Scope определяется позицией в дереве: ChangeNotifierProvider(create: ...) создаёт notifier один раз для своего Element и диспоузит при удалении этого элемента
Пересоздаёт не ребилд, а новый элемент: поднимите провайдер выше TabBarView или выше маршрута, и он проживёт дольше; на корне приложения он синглтон
**.value ничего не диспоузит: он для объекта, которым владеют в другом месте; создание объекта прямо в нём приводит к утечке
Riverpod устроен наоборот**: провайдеры глобальные, время жизни задают слушатели: autoDispose завершает его с уходом последнего, ref.keepAlive() закрепляет
Про вкладки: TabBarView уничтожает страницы вне экрана; помогут AutomaticKeepAliveClientMixin, IndexedStack или подъём state holder выше вкладок
Чинить надо репозиторий, а не экраны: каждый экран загрузил свою копию, единого источника правды нет. Пусть репозиторий владеет закэшированным пользователем и отдаёт поток, на который подписаны все экраны.
Один писатель, много читателей: запись идёт в API, результат кладётся в хранилище, хранилище эмитит; ни один экран не присваивает ответ себе
С локальной базой это бесплатно: watch() в Drift, слушатели Isar, снапшоты Firestore срабатывают на запись; без базы ту же работу делает BehaviorSubject (rxdart) или broadcast-StreamController, повторяющий последнее значение
Отдавайте поздним подписчикам текущее значение: обычный broadcast-поток экрану, открытому после последнего эмита, не отдаст ничего, и тот останется пустым навсегда
Оптимистичное обновление требует отката: покажите новое имя сразу, но сохраните старое и верните его, если запрос упал
Частая ошибка: кэшировать в state holder: у каждого экрана своя копия, и всё держится на том, что никто не забыл инвалидацию
Репозиторий превращает любую ошибку в пустой список, и Cubit не может отличить «нет заказов» от «нет сети»: он показывает пустое состояние, кнопка «Повторить» не появляется, а ответ 500 или сломанный маппинг выглядят точно так же.
Почему так происходит: [] считается допустимым результатом, поэтому ошибка стирается на границе слоя, и всё, что выше, видит успешный вызов; строка с log создаёт видимость обработки, но об ошибке узнает только тот, кто читает логи устройства
Скрывает не только сеть: голый catch глотает и 401, и 500, и TypeError из сломанного toEntity(), так что изменение контракта бэкенда доезжает до всех пользователей как «нет заказов», а не как краш-репорт
Исправление: ловите только транспортное исключение и пробрасывайте его дальше как AppError; Cubit ловит AppError и эмитит OrdersFailed, у вью которого есть кнопка «Повторить». Баг маппинга летит дальше, в PlatformDispatcher.instance.onError и краш-репортинг, где ему и место; при этом экрану всё равно нужен общий фолбэк OrdersFailed(UnknownError()) (например, из onError), иначе load() бросит исключение и спиннер не остановится
Когда фолбэк уместен: отдавать закэшированные заказы офлайн нормально, но возвращайте их как кэш с пометкой «офлайн», а не как выдуманный пустой список
Как поймать в следующий раз: тест репозитория с фейковым API, который бросает исключение, должен ожидать throwsA(isA<NetworkError>()); линт avoid_catches_without_on_clauses подсвечивает голый catch
Группируйте файлы по фичам, а не по слоям: в lib/features/cart/ лежат модели, репозиторий, state holder и экраны корзины, а не разбросаны по lib/models/, lib/blocs/ и lib/screens/.
При раскладке по слоям добавление одной фичи заставляет править четыре папки, а удаление заставляет искать её файлы по всему проекту. Папка фичи представляет собой одно место: прочитать, отдать команде или удалить целиком.
Внутри фичи: те же слои (data, domain, presentation), только в рамках этой фичи
Общий код: lib/core/: тема, HTTP-клиент, логирование; ничего специфичного для одной фичи
Никаких импортов между фичами: если корзине нужен auth, идите через общий интерфейс или composition root
Запас на рост: каждую папку можно превратить в локальный пакет, в pubspec.yaml которого перечислено ровно то, чем ему можно пользоваться; добавьте в CI линт границ импортов
Владение: одна запись CODEOWNERS на папку фичи
Частая ошибка: lib/core/, который тихо становится вторым приложением: если им пользуется одна фича, код принадлежит ей
Вынесите каждую фичу в отдельный локальный пакет в packages/, а тонкий пакет app/ пусть зависит от них. Пакет видит только то, что объявил в pubspec.yaml, поэтому граница из договорённости становится явным списком зависимостей.
Это оправдано, когда случайные импорты между фичами и долгие пересборки начинают реально мешать; речь про несколько команд, а не про несколько экранов.
Инструменты: pub workspaces (workspace: в корневом pubspec.yaml, Dart 3.6+) дают одно общее разрешение зависимостей и один pub get; Melos добавляет поверх скрипты и версионирование
Контроль: сделайте линт depend_on_referenced_packages ошибкой в CI, потому что внутри workspace импорт незаявленного соседнего пакета всё равно разрешается
Чистые Dart-пакеты: у domain и core нет зависимости от Flutter, поэтому они прогоняются через dart test за секунду
Plugin-пакеты: всё, что везёт с собой Kotlin или Swift, даже если это нужно только вашему приложению
Что не разбивать: маленькое приложение и две тесно связанные фичи: сначала развяжите связность
Готовьтесь к рефакторингу: пакеты заставляют объявить публичный API; в этом и смысл, и основная работа
Стройте весь граф объектов в одном месте (composition root, обычно main.dart) и передавайте зависимости вниз через конструкторы. Бизнес-код никогда не достаёт зависимость сам.
В масштабе сложна не проводка, а время жизни: решите, что живёт столько же, сколько приложение, что живёт, пока залогинен пользователь, а что только пока открыт экран.
Три scope-а: приложение (Dio, логгер), пользователь (всё, что должно умереть при выходе), экран (уничтожается вместе с маршрутом)
Биндите интерфейсы: Bloc видит AuthRepository, а не DioAuthRepository, и тесты с флейворами могут его подменить
Окружения задаются просто переопределениями: dev, staging, prod и тесты: один граф с разными привязками
Частая ошибка: MyService.instance.method() внутри бизнес-кода: глобал с лишними шагами, который не протестировать
Ловите транспортные исключения один раз, в репозитории, и перебрасывайте их как sealed class AppError; view model кладёт её в состояние, а UI делает по ней switch. Ожидаемый бизнес-исход («логин занят», отказ карты) возвращается как Result, а не как исключение.
Само дерево: только случаи, которые UI показывает по-разному: NetworkError, Unauthorized, ServerError(statusCode), UnknownError; ValidationError(fields) добавляйте, когда это понадобится форме
Маппинг на границе: репозиторий ловит DioException или SocketException и бросает AppError (сам маппинг разобран в теме про сеть); выше него транспортных типов нет
Показ в UI: view model кладёт ошибку в состояние (Failed(error)), а виджет делает switch; switch по sealed-типу исчерпывающий, поэтому новый подкласс роняет компиляцию, пока его не обработают на каждом экране
UI решает по случаю: Unauthorized ведёт на логин, NetworkError показывает баннер с повтором
Result или throw: бросайте для непредвиденного (нет сети, 500); возвращайте Result или sealed-исход для того, что вызывающие обязаны обработать, потому что тип их к этому вынуждает
Feature flag отделяет деплой от релиза: код уезжает выключенным, а включаете вы его позже (части пользователей или всем сразу).
Где живёт правда: сервис remote config, а дефолты зашиты в приложение, чтобы первый запуск без сети вёл себя разумно
Один тонкий клиент: заверните всё в свой класс FeatureFlags, возвращающий обычные bool, кэшируйте последние значения, обновляйте при возврате в foreground
Где читать флаг: лучше в composition root, выбирая реализацию; россыпь if (flag) по всему UI стареет плохо
Kill switch: флаг, читаемый в рантайме, выключает сломанную фичу за минуты без ревью стора; если конфиг не загрузился, берите безопасное состояние
A/B-тесты: тот же класс FeatureFlags возвращает вариант пользователя; пишите вариант в каждое аналитическое событие, иначе не понять, что сдвинуло метрику
У каждого флага есть срок: эксперимент, раскатка, затем удаление флага вместе с мёртвой веткой; тикет на удаление заводите сразу
Не давайте фичам импортировать друг друга: это тот самый цикл, который потом никто не распутает. Либо координатор в composition root дёргает узкий интерфейс каждой фичи, либо auth публикует событие, а желающие подписываются.
Координатор: SessionCoordinator.signOut() в заданном порядке запускает cart.onSignedOut(), chat.onSignedOut() и images.onSignedOut(), затем чистит токен; вся проводка умещается в одном файле, который легко найти
Поток событий: AuthRepository отдаёт Stream<AuthEvent>, и каждая фича подписывается сама; никто не знает, кто слушает; это и плюс, и цена
Что выбрать: координатор, когда эффектов мало, они упорядочены и обязательны; поток, когда их много и они необязательны
Порядок важен: сначала гасите локальное состояние, токен чистите последним, иначе сокет будет переподключаться и ловить 401
Частая ошибка: исключение в одном обработчике убивает остаток; оборачивайте и логируйте каждый, чтобы упавший кэш картинок не оставил корзину полной
Последовательностью владеет use case: она охватывает три репозитория, ровно для этого и нужен доменный слой. Транзакции на три сервиса не существует, поэтому упавшее списание лечится компенсацией и идемпотентностью, а не откатом.
Не репозиторий: OrderRepository пришлось бы импортировать платежи и корзину, воссоздав ту самую связность, ради которой их и разделяли
Не Cubit: правило жило бы ровно столько, сколько экран; если закрыть его на середине, заказ окажется оформлен наполовину
Один ключ идемпотентности на попытку: отправляется на каждом шаге и переиспользуется при повторе, чтобы сервер дедуплицировал, а не списал дважды
Компенсируйте в обратном порядке: упали на шаге N: снимите резерв и верните типизированный результат; корзину чистите только после того, как деньги списаны
Лучше один вызов сервера: если бэкенд умеет, сделайте один идемпотентный placeOrder, а резерв, списание и компенсацию пусть ведёт сервер; эта последовательность остаётся запасным вариантом
Отказ карты не считается исключением: это нормальный бизнес-исход, и место ему в типе результата; исключения оставьте для непредвиденного
Выдавливайте старый код постепенно (паттерн Strangler), а не переписывайте: зафиксируйте старую форму, все новые фичи делайте в целевой, а старое переносите только тогда, когда и так открыли этот файл.
Сначала зафиксируйте поведение: виджет- и golden-тесты на область, которую собираетесь трогать, вместе со всеми уродствами; рефакторинг без них превращается в гадание
Заведите composition root: старый сервис-локатор оставьте, но пусть он делегирует в root, чтобы оба мира видели один экземпляр
По одной фиче: интерфейс репозитория перед доступом к данным, затем логика переезжает из StatefulWidget-ов в state holder
Закрепляйте каждый шаг: перемещения файлов отдельным коммитом, а границы импортов и запрет новых глобальных синглтонов фиксируйте линтами
Частая ошибка: параллельная ветка с переписыванием: она расходится с main и там же умирает