Лекция 10. Clean Architecture и слой данных
Введение
По мере роста мобильного приложения главным врагом становится не сложность отдельной функции, а связанность (coupling) между частями системы. Когда экран напрямую обращается к HTTP-клиенту, парсит JSON, складывает данные в локальную базу и тут же их рисует, любая мелочь — смена API, добавление кеша, переход с AsyncStorage на SQLite — заставляет переписывать UI. Тестировать такой код почти невозможно: чтобы проверить одну формулу, нужно поднять сеть и базу.
Clean Architecture (Роберт Мартин) — способ организовать код так, чтобы бизнес-правила приложения не зависели от деталей: ни от React Native, ни от библиотеки навигации, ни от способа хранения данных. В этой лекции мы разберём:
- слои Clean Architecture и главное правило зависимостей;
- use cases (интеракторы) и доменные сущности, отделённые от UI;
- репозиторий как фасад над разными источниками данных;
- политики кэширования: Cache-First, Network-First, Stale-While-Revalidate;
- offline-first архитектуру, очередь изменений и синхронизацию, а также то, как из теоремы CAP следуют практические компромиссы.
1. Идея Clean Architecture
1.1. Проблема, которую решаем
В типичном «быстром» приложении зависимости направлены хаотично: компонент знает про сеть, сеть знает про формат хранения, всё знает про всё. Изменение в одном месте расходится волной по всему проекту. Clean Architecture предлагает дисциплину: разложить код по концентрическим слоям и разрешить зависимостям указывать только внутрь — от деталей к абстракциям. Тогда центр системы (бизнес-правила) ничего не знает о внешнем мире и остаётся стабильным.
1.2. Слои
Слои принято рисовать как вложенные кольца. Чем ближе к центру — тем абстрактнее и стабильнее код; чем ближе к краю — тем больше деталей и тем чаще они меняются.
+-----------------------------------------------------+ | Frameworks & Drivers: RN/Expo, fetch, SQLite, ... | | +-------------------------------------------+ | | | Interface Adapters: репозитории, мапперы, | | | | ViewModel/презентеры, DTO | | | | +-----------------------------------+ | | | | | Use Cases: интеракторы, сценарии, | | | | | | интерфейсы репозиториев (порты) | | | | | | +---------------------------+ | | | | | | | Entities (Domain): | | | | | | | | сущности, правила, VO | | | | | | | +---------------------------+ | | | | | +-----------------------------------+ | | | +-------------------------------------------+ | +-----------------------------------------------------+
Зависимости направлены ТОЛЬКО внутрь ---> (к центру)- Entities (Domain) — сущности и правила предметной области (
User,Todo, их инварианты и чистая логика). Не знают ни о React, ни о сети, ни о базе. - Use Cases (Application) — сценарии («получить список задач», «авторизоваться»). Оркеструют сущности и обращаются к данным через интерфейсы (порты), не зная реализаций.
- Interface Adapters — переходники: реализации репозиториев, мапперы DTO ↔ Entity, ViewModel/презентеры.
- Frameworks & Drivers — детали: React Native и Expo, HTTP-клиент, SQLite/AsyncStorage, React Navigation. Самый «толстый» и заменяемый слой.
1.3. Главное правило — Dependency Rule
Зависимости в исходном коде указывают только внутрь. Внутренний слой ничего не знает о внешнем.
Из этого следует:
- сущности из внешнего слоя не упоминаются во внутреннем (домен не импортирует
react,axios,expo-sqlite); - поток управления может идти наружу (use case вызывает базу), но зависимость по коду всё равно направлена внутрь — благодаря инверсии зависимостей (ниже);
- бизнес-правила тестируются без эмулятора, сети и базы — достаточно подставить фейковые реализации интерфейсов.
1.4. Инверсия зависимостей (DIP) как механизм
Как use case может «получить задачи из сети», не завися от сети? Через инверсию зависимостей: use case зависит не от конкретного источника, а от интерфейса (порта), который сам же и объявляет. Реализацию предоставляет внешний слой.
Use Case --(зависит от)--> interface TodoRepository [domain] ^ implements HttpTodoRepository [data]Стрелка реализации указывает внутрь: деталь (HttpTodoRepository) зависит от
абстракции (TodoRepository), а не наоборот. Так мы сохраняем чистоту домена.
2. Как это ложится на RN/Expo-проект
Слои удобно отразить в структуре каталогов. Один из вариантов:
src/ domain/ entities/ # сущности: Todo, User (чистые типы и правила) usecases/ # сценарии: GetTodos, ToggleTodo, Login repositories/ # ИНТЕРФЕЙСЫ репозиториев (порты) data/ sources/ # remote (http), local (sqlite, asyncstorage) repositories/ # РЕАЛИЗАЦИИ интерфейсов из domain mappers/ # DTO <-> Entity presentation/ screens/ # экраны (React-компоненты) viewmodels/ # хуки с логикой представления navigation/ # навигаторы и маршруты app/ di/ # сборка зависимостей (контейнер/фабрики) config/ # окружение, константыКлючевое наблюдение: интерфейсы репозиториев живут в domain, а их
реализации — в data. Это инверсия зависимостей в действии. В больших
проектах ту же структуру дублируют внутри feature-модулей
(features/todos/{domain,data,presentation}).
3. Доменный слой: сущности и use cases
3.1. Сущности отдельно от UI
Доменная сущность описывает данные и правила предметной области и не содержит ничего из мира представления — ни цветов, ни форматов отображения, ни полей, нужных только конкретному экрану.
export interface Todo { id: string; title: string; completed: boolean; updatedAt: number; // метка времени для синхронизации}Это намеренно не то же, что DTO с сервера (is_done, title_text) и не
view-модель экрана (displayDate, iconColor). Преобразованиями занимаются
мапперы в слое данных и ViewModel в презентации.
3.2. Use case (интерактор)
Use case — это один сценарий приложения, обычно класс с единственным методом
execute. Он зависит только от доменных интерфейсов.
export interface TodoRepository { getAll(): Promise<Todo[]>; getById(id: string): Promise<Todo | null>; save(todo: Todo): Promise<void>; remove(id: string): Promise<void>;}
// domain/usecases/ToggleTodo.tsexport class ToggleTodo { constructor(private readonly repo: TodoRepository) {}
async execute(id: string): Promise<void> { const todo = await this.repo.getById(id); if (!todo) throw new Error("Todo not found"); // бизнес-правило: переключение статуса + обновление метки времени await this.repo.save({ ...todo, completed: !todo.completed, updatedAt: Date.now(), }); }}Use case не знает, придут ли данные из сети, из SQLite или из кеша — он работает
с абстракцией TodoRepository. Это делает его тривиально тестируемым:
достаточно передать фейковый репозиторий.
4. Репозиторий как фасад
4.1. Идея фасада
Репозиторий — это фасад (Facade), скрывающий от верхних слоёв всё разнообразие источников данных: HTTP-эндпоинты, локальную базу, кеш, форматы DTO. Снаружи — простой устойчивый интерфейс в терминах домена; внутри он сам решает, откуда взять данные, что закешировать и как смержить.
Use Case --> TodoRepository (интерфейс) <-- домен видит только это | CachedTodoRepository <-- слой data / \ RemoteSource LocalSource (http/fetch) (SQLite / AsyncStorage)4.2. Пример реализации
export class CachedTodoRepository implements TodoRepository { constructor( private readonly remote: RemoteTodoSource, private readonly local: LocalTodoSource, private readonly mapper: TodoMapper, ) {}
async getAll(): Promise<Todo[]> { try { const dtos = await this.remote.fetchAll(); // сеть const todos = dtos.map(this.mapper.toEntity); await this.local.replaceAll(todos); // обновляем кеш return todos; } catch (e) { // сеть недоступна — отдаём локальную копию return this.local.getAll(); } } // ...save / remove / getById}Репозиторий инкапсулирует политику работы с источниками: верхние слои не знают, что внутри есть fallback на локальную базу.
4.3. Зачем это нужно
- Заменяемость источников — переход с REST на GraphQL или с AsyncStorage на SQLite меняет реализацию, не трогая домен и UI.
- Тестируемость — в тестах подставляем in-memory реализацию.
- Единая точка для кеша и ошибок — логика кеширования, ретраев и маппинга ошибок собрана в одном месте, а не размазана по экранам.
5. Политики кэширования
Кеш — компромисс между свежестью данных и скоростью/доступностью. Выбор политики зависит от того, что важнее для конкретного экрана: всегда актуальные данные или мгновенный отклик и работа офлайн.
5.1. Три базовые политики
- Cache-First (cache-then-network как частный случай). Сначала смотрим в кеш; если данные есть — отдаём их и сеть можем не трогать. Быстро и экономно, но данные могут устареть.
- Network-First. Сначала идём в сеть; если получилось — отдаём свежие данные и обновляем кеш; если сеть недоступна — откатываемся на кеш. Свежо, но медленнее и зависит от соединения.
- Stale-While-Revalidate (SWR). Сразу отдаём данные из кеша («stale»), чтобы UI отрисовался мгновенно, и параллельно идём в сеть; когда придёт свежий ответ — тихо обновляем экран. Лучший баланс отзывчивости и свежести.
5.2. Сравнительная таблица
| Политика | Источник первым | Свежесть | Скорость отклика | Работа офлайн | Когда применять |
|---|---|---|---|---|---|
| Cache-First | Кеш | Низкая/средняя | Высокая | Да (если есть кеш) | Редко меняющиеся данные: справочники, профиль, настройки |
| Network-First | Сеть | Высокая | Низкая | Только как fallback | Критично свежие данные: баланс, статус заказа, лента в реальном времени |
| Stale-While-Revalidate | Кеш + фон. сеть | Средняя/высокая | Высокая | Да (показ кеша) | Списки и ленты, где важна и отзывчивость, и актуальность |
5.3. Практическая заметка
В RN/Expo политики редко пишут руками: для серверного состояния используют TanStack Query (React Query) или SWR, которые из коробки дают stale-while-revalidate, инвалидацию, дедупликацию и фоновое обновление. Библиотека лишь реализует ту же логику, которую иначе складывали бы в репозиторий.
6. Offline-first архитектура
6.1. Принцип «локальная БД — источник истины»
В offline-first приложении UI читает и пишет данные в локальную базу, а не напрямую в сеть. Сеть становится фоновым процессом синхронизации. Пользователь работает мгновенно и не зависит от качества соединения; изменения «уезжают» на сервер, когда появляется связь.
UI / Use Case | (читает и пишет мгновенно) v Local DB (источник истины) <----+ | | синхронизация v | в фоне Outbox / очередь изменений -----+----> Remote API6.2. Очередь изменений (outbox)
Каждое локальное изменение, которое нужно отправить на сервер, записывается в очередь операций (outbox): «создать задачу X», «обновить Y», «удалить Z». Очередь обеспечивает надёжность (операция не теряется при закрытии приложения), порядок (изменения применяются в нужной последовательности) и идемпотентность (повтор операции после обрыва не ломает данные — помогают стабильные client-side id и версии записей).
6.3. Синхронизация при восстановлении сети
Когда соединение возвращается (отслеживаем, например, через NetInfo),
запускается синхронизация:
- отправить накопленные операции из очереди на сервер (push);
- получить с сервера изменения, произошедшие за время офлайна (pull);
- разрешить конфликты и обновить локальную базу;
- очистить успешно применённые элементы очереди.
6.4. Разрешение конфликтов
Конфликт возникает, когда одну и ту же запись поменяли и на клиенте, и на сервере. Типовые стратегии:
- Last-Write-Wins (LWW) — побеждает запись с большей меткой времени/версией. Просто, но может терять изменения.
- Merge по правилам — объединяем поля по доменной логике (например, суммируем счётчики, объединяем списки).
- Ручное разрешение — показываем пользователю выбор. Самая точная, но дорогая по UX стратегия.
Для всего этого записям нужны версии или временные метки (updatedAt,
version) — без них определить «кто новее» невозможно.
7. CAP и следствия для мобильных приложений
7.1. Теорема CAP
Теорема CAP: распределённая система при сетевом разделении (Partition) не может одновременно гарантировать строгую согласованность (Consistency) и доступность (Availability). Когда связь рвётся, приходится выбирать: отказать в обслуживании ради согласованности (CP) или работать, временно жертвуя согласованностью (AP).
7.2. Мобильный клиент — это всегда «раздел»
Телефон постоянно теряет и восстанавливает связь — network partition тут не исключение, а норма. Поэтому offline-first приложения почти всегда выбирают AP: остаёмся доступными (пользователь работает локально), а согласованность делаем итоговой (eventual consistency) — данные сойдутся после синхронизации.
7.3. Практические выводы
- Стройте UI в расчёте на временное расхождение локальных данных с сервером (оптимистичные обновления, индикатор «не синхронизировано»).
- Закладывайте версии/метки времени и стратегию конфликт-резолвинга с самого начала — задним числом их добавить дорого.
- Помните о компромиссе: offline-first и агрессивное кеширование повышают доступность и скорость, но усложняют согласованность. Это сознательный выбор, который стоит зафиксировать (например, в ADR).
Краткие итоги
- Clean Architecture делит код на слои Entities → Use Cases → Interface Adapters → Frameworks; зависимости направлены только внутрь (Dependency Rule).
- Инверсия зависимостей (интерфейсы в домене, реализации снаружи) освобождает домен от знания о сети, базе и React; сущности и use cases тестируются без эмулятора.
- Репозиторий — фасад над источниками данных: единый интерфейс в терминах домена, скрытая внутри логика сети, кеша и БД.
- Политики кэширования: Cache-First (скорость), Network-First (свежесть), Stale-While-Revalidate (баланс). Выбор зависит от требований экрана.
- Offline-first делает локальную БД источником истины, копит изменения в очереди (outbox) и синхронизирует их при восстановлении сети с разрешением конфликтов.
- Из CAP следует, что мобильные клиенты выбирают доступность (AP) и итоговую согласованность; для этого нужны версии/метки времени.
Вопросы для самопроверки
- Объясните концепцию Clean Architecture применительно к мобильным приложениям. Какие слои выделяются и куда направлены зависимости?
- Что такое репозиторий как фасад? Как он скрывает детали источников данных и почему интерфейс репозитория размещают в доменном слое?
- Опишите политики кэширования: Cache-First, Network-First, Stale-While-Revalidate. Когда уместна каждая из них?
- Что такое offline-first архитектура? Как реализовать очередь изменений и синхронизацию данных при восстановлении соединения, и какие следствия из теоремы CAP при этом возникают?