Архитектура мобильного приложения: MVVM и Clean Architecture
Продвинутое4 минРедакция Innovialabs
Коротко
MVVM держит логику экрана во ViewModel, которая отдаёт состояние, а Clean Architecture добавляет слой domain с use case и интерфейсами репозиториев, чтобы бизнес-правила не зависели от UI, сети и базы; для небольших приложений обычно хватает MVVM с репозиториями.
Коротко
MVVM и Clean Architecture решают разные задачи и хорошо сочетаются:
- MVVM упорядочивает экран: View только отрисовывает состояние и передаёт события, а ViewModel хранит логику экрана и отдаёт состояние.
- Clean Architecture упорядочивает всё приложение: бизнес-правила живут в слое domain, который ничего не знает о UI-фреймворке, HTTP-клиенте и базе данных.
На практике получается три слоя: presentation (экраны и ViewModel), domain (use case, сущности, интерфейсы репозиториев) и data (API, локальная база, реализации репозиториев). Зависимости направлены внутрь: presentation и data зависят от domain, а domain — ни от чего.
Что лежит в каждом слое
| Слой | Содержит | Не должен содержать |
|---|---|---|
| Presentation | Экраны, ViewModel, UI-состояние, навигация | SQL, HTTP-запросы, бизнес-правила |
| Domain | Сущности, use case, интерфейсы репозиториев | Импорты фреймворков (Android, UIKit, Flutter) |
| Data | API-клиенты, DTO, база, кэш, реализации репозиториев | UI-состояние, логику экранов |
Простая проверка слоя domain: он должен компилироваться и покрываться unit-тестами как чистый Kotlin, Swift или Dart, без эмулятора.
Как это выглядит в коде
Минимальный пример на Kotlin. Domain объявляет, что ему нужно, data это предоставляет, а ViewModel только превращает результат в состояние.
// domain: без импортов Android
interface OrderRepository {
suspend fun getOrders(): List<Order>
}
class GetActiveOrders(private val repo: OrderRepository) {
suspend operator fun invoke(): List<Order> =
repo.getOrders().filter { it.isActive }
}
// presentation
class OrdersViewModel(
private val getActiveOrders: GetActiveOrders
) : ViewModel() {
private val _state = MutableStateFlow<OrdersState>(OrdersState.Loading)
val state: StateFlow<OrdersState> = _state
fun load() {
viewModelScope.launch {
_state.value = try {
OrdersState.Content(getActiveOrders())
} catch (e: IOException) {
OrdersState.Error
}
}
}
}
В слое data OrderRepositoryImpl получает API-клиент и DAO, решает, отдавать ли кэш, и преобразует DTO в доменные сущности. ViewModel никогда не видит JSON и строки базы.
Та же схема работает в Swift (protocol вместо interface, view model через ObservableObject или @Observable) и во Flutter (абстрактный класс плюс Bloc, Cubit или Riverpod notifier).
Dependency injection
Слои получают зависимости через конструктор, а не создают их сами. Dependency injection (DI) — это просто место, где собирается граф объектов.
- Android: Hilt (построен на Dagger) или Koin.
- iOS: часто обычная инъекция через конструктор и composition root; при необходимости есть библиотеки вроде Factory или Swinject.
- Flutter: get_it, injectable или провайдеры Riverpod.
Правила здоровой DI:
- Связывайте интерфейсы с реализациями в одном месте (
OrderRepositoryсOrderRepositoryImpl). - Внедряйте зависимости только через конструкторы, избегайте service locator глубоко внутри классов.
- В тестах подменяйте реальные реализации фейками. Если это сложно — границы проведены неверно.
Структура модулей
Группируйте сначала по фичам, а внутри фичи — по слоям:
app/
core/
network/
database/
ui/
feature/
orders/
presentation/
domain/
data/
profile/
Начинайте с пакетов или папок. Переходите на отдельные Gradle-модули, Swift packages или Dart packages, когда растёт время сборки или параллельно работают несколько команд: модули проверяют границы компилятором, а не дисциплиной.
Когда это избыточно
Полная Clean Architecture стоит лишних файлов, маппингов и уровней косвенности. Она может не окупиться, если:
- приложение — прототип или MVP, который могут переписать;
- экраны в основном показывают данные из API без собственных правил;
- его поддерживает один разработчик, и срок жизни короткий.
В таких случаях берите MVVM плюс репозитории: ViewModel обращается к репозиторию напрямую, без use case. Слой domain можно добавить позже, по одной фиче, когда появятся настоящие бизнес-правила.
Признаки, что полный подход нужен: сложные правила (цены, права доступа, статусы), офлайн-режим с синхронизацией, несколько источников данных, несколько команд или логика, общая для платформ.
Частые ошибки
- Use case-прокладки, которые просто вызывают один метод репозитория. Не создавайте их, если нет правил.
- Типы фреймворка в domain:
Context,UIImage,BuildContextили JSON-аннотации. - ViewModel хранит ссылки на View или управляет навигацией через UI-объекты.
- Огромная BaseViewModel, от которой наследуются все экраны и которую страшно менять.
- Слишком много модулей слишком рано: конфигурация сборки растёт быстрее продукта.
- Одна модель на всё: DTO, протекающий в UI, привязывает экраны к формату бэкенда.