Перейти к содержанию
Innovialabs

Архитектура мобильного приложения: MVVM и Clean Architecture

Продвинутое4 мин

Коротко

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)
DataAPI-клиенты, 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, привязывает экраны к формату бэкенда.

Частые вопросы

Нет. Use case имеет смысл, когда в нём есть правило, он объединяет несколько репозиториев или переиспользуется на разных экранах. Простое чтение данных может идти из ViewModel прямо в репозиторий.

Поможем с этим

Если хотите не разбираться самим, а получить результат — этим мы занимаемся каждый день.

Обсудить проект

Читайте также

Есть идея или задача? Расскажите нам

Ответим в течение рабочего дня и предложим решение. Консультация бесплатная.