Перейти к содержимому

Лекция 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

Доменная сущность описывает данные и правила предметной области и не содержит ничего из мира представления — ни цветов, ни форматов отображения, ни полей, нужных только конкретному экрану.

domain/entities/Todo.ts
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. Он зависит только от доменных интерфейсов.

domain/repositories/TodoRepository.ts
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.ts
export 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. Пример реализации

data/repositories/CachedTodoRepository.ts
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 API

6.2. Очередь изменений (outbox)

Каждое локальное изменение, которое нужно отправить на сервер, записывается в очередь операций (outbox): «создать задачу X», «обновить Y», «удалить Z». Очередь обеспечивает надёжность (операция не теряется при закрытии приложения), порядок (изменения применяются в нужной последовательности) и идемпотентность (повтор операции после обрыва не ломает данные — помогают стабильные client-side id и версии записей).

6.3. Синхронизация при восстановлении сети

Когда соединение возвращается (отслеживаем, например, через NetInfo), запускается синхронизация:

  1. отправить накопленные операции из очереди на сервер (push);
  2. получить с сервера изменения, произошедшие за время офлайна (pull);
  3. разрешить конфликты и обновить локальную базу;
  4. очистить успешно применённые элементы очереди.

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) и итоговую согласованность; для этого нужны версии/метки времени.

Вопросы для самопроверки

  1. Объясните концепцию Clean Architecture применительно к мобильным приложениям. Какие слои выделяются и куда направлены зависимости?
  2. Что такое репозиторий как фасад? Как он скрывает детали источников данных и почему интерфейс репозитория размещают в доменном слое?
  3. Опишите политики кэширования: Cache-First, Network-First, Stale-While-Revalidate. Когда уместна каждая из них?
  4. Что такое offline-first архитектура? Как реализовать очередь изменений и синхронизацию данных при восстановлении соединения, и какие следствия из теоремы CAP при этом возникают?