Практика 10. Практическая работа 10. Слой данных: репозиторий и кеширование
Цели работы
- Построить слой данных по принципам Clean Architecture: домен не знает ни о сети, ни о хранилище.
- Реализовать репозиторий-фасад, который скрывает за единым интерфейсом два источника — удалённый (API, mock) и локальный (AsyncStorage/память).
- Реализовать и сравнить две политики кэширования: Cache-First и Network-First.
- Показать, что UI-слой работает только с репозиторием и ничего не знает об источниках данных.
- Набросать архитектуру offline-first: очередь несинхронизированных изменений и синхронизацию при «появлении сети».
Коротко о теории
Связь с лекцией 10. Главное правило Clean Architecture — Dependency Rule: зависимости направлены только внутрь, к домену. Поэтому интерфейс репозитория объявляется в домене, а его реализация живёт в слое данных. Это инверсия зависимостей: деталь (HTTP, AsyncStorage) зависит от абстракции, а не наоборот.
Репозиторий — это фасад: снаружи устойчивый интерфейс в терминах домена, внутри — выбор источника, кеш, маппинг ошибок. Политики кэширования из лекции:
- Cache-First — сначала кеш; если данные есть, отдаём их, сеть можем не трогать. Быстро, но данные могут устареть.
- Network-First — сначала сеть; при ошибке откатываемся на кеш. Свежо, но медленнее и зависит от соединения.
- Stale-While-Revalidate — отдаём кеш сразу и параллельно обновляем из сети (на практике обычно через TanStack Query/SWR).
Offline-first делает локальную базу источником истины, копит изменения в очереди (outbox) и синхронизирует их при восстановлении сети. Из CAP-теоремы следует, что мобильный клиент выбирает доступность (AP) и итоговую согласованность.
Задание
Реализуйте слой данных для сущности Note (заметка). Каркас структуры папок:
src/ domain/ entities/Note.ts repositories/NoteRepository.ts # интерфейс (порт) data/ sources/RemoteNoteSource.ts # mock API sources/LocalNoteSource.ts # AsyncStorage / память repositories/CachedNoteRepository.ts presentation/ useNotes.ts # хук NotesScreen.tsx # экранШаг 1. Доменная сущность и интерфейс репозитория
Сущность чистая: никаких импортов React, сети или хранилища. Поле updatedAt понадобится для синхронизации.
export interface Note { id: string; title: string; body: string; updatedAt: number; // метка времени для разрешения конфликтов}
// domain/repositories/NoteRepository.tsimport { Note } from "../entities/Note";
export interface NoteRepository { getAll(policy?: "cache-first" | "network-first"): Promise<Note[]>; save(note: Note): Promise<void>; remove(id: string): Promise<void>;}Шаг 2. Два источника данных
Удалённый источник имитирует сеть (задержка и случайные сбои). Локальный источник кеширует данные в AsyncStorage.
import { Note } from "../../domain/entities/Note";
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));let server: Note[] = [ { id: "1", title: "Купить хлеб", body: "...", updatedAt: Date.now() },];
export class RemoteNoteSource { async fetchAll(): Promise<Note[]> { await delay(700); if (Math.random() < 0.3) throw new Error("Network error"); // имитация офлайна return server; } async push(note: Note): Promise<void> { await delay(400); server = [note, ...server.filter((n) => n.id !== note.id)]; }}import AsyncStorage from "@react-native-async-storage/async-storage";import { Note } from "../../domain/entities/Note";
const KEY = "notes_cache";
export class LocalNoteSource { async getAll(): Promise<Note[]> { const raw = await AsyncStorage.getItem(KEY); return raw ? (JSON.parse(raw) as Note[]) : []; } async replaceAll(notes: Note[]): Promise<void> { await AsyncStorage.setItem(KEY, JSON.stringify(notes)); }}Шаг 3. Репозиторий-фасад с двумя политиками
Вся логика выбора источника спрятана здесь. Снаружи виден только интерфейс из домена.
import { Note } from "../../domain/entities/Note";import { NoteRepository } from "../../domain/repositories/NoteRepository";import { RemoteNoteSource } from "../sources/RemoteNoteSource";import { LocalNoteSource } from "../sources/LocalNoteSource";
export class CachedNoteRepository implements NoteRepository { constructor( private readonly remote: RemoteNoteSource, private readonly local: LocalNoteSource, ) {}
async getAll(policy: "cache-first" | "network-first" = "cache-first") { return policy === "cache-first" ? this.cacheFirst() : this.networkFirst(); }
// Cache-First: есть кеш — отдаём сразу; иначе идём в сеть и заполняем кеш. private async cacheFirst(): Promise<Note[]> { const cached = await this.local.getAll(); if (cached.length > 0) return cached; const fresh = await this.remote.fetchAll(); await this.local.replaceAll(fresh); return fresh; }
// Network-First: пробуем сеть; при ошибке — fallback на кеш. private async networkFirst(): Promise<Note[]> { try { const fresh = await this.remote.fetchAll(); await this.local.replaceAll(fresh); // обновляем кеш return fresh; } catch { return this.local.getAll(); } }
async save(note: Note): Promise<void> { const notes = await this.local.getAll(); const next = [note, ...notes.filter((n) => n.id !== note.id)]; await this.local.replaceAll(next); // пишем локально мгновенно await this.remote.push(note); // затем в сеть }
async remove(id: string): Promise<void> { const notes = await this.local.getAll(); await this.local.replaceAll(notes.filter((n) => n.id !== id)); }}Шаг 4. UI работает только с репозиторием
Экран и хук не знают ни про RemoteNoteSource, ни про AsyncStorage — только про интерфейс NoteRepository. Реализацию подставляем в одном месте (сборка зависимостей).
import { useEffect, useState } from "react";import { Note } from "../domain/entities/Note";import { NoteRepository } from "../domain/repositories/NoteRepository";
export function useNotes(repo: NoteRepository) { const [notes, setNotes] = useState<Note[]>([]); const [loading, setLoading] = useState(true);
async function load() { setLoading(true); setNotes(await repo.getAll("network-first")); setLoading(false); }
useEffect(() => { load(); }, []); return { notes, loading, reload: load };}import { View, Text, FlatList, Button } from "react-native";import { useNotes } from "./useNotes";import { CachedNoteRepository } from "../data/repositories/CachedNoteRepository";import { RemoteNoteSource } from "../data/sources/RemoteNoteSource";import { LocalNoteSource } from "../data/sources/LocalNoteSource";
// Сборка зависимостей (DI) — единственное место, знающее об источниках.const repo = new CachedNoteRepository(new RemoteNoteSource(), new LocalNoteSource());
export default function NotesScreen() { const { notes, loading, reload } = useNotes(repo); if (loading) return <Text>Загрузка…</Text>; return ( <View style={{ flex: 1, padding: 16 }}> <Button title="Обновить" onPress={reload} /> <FlatList data={notes} keyExtractor={(n) => n.id} renderItem={({ item }) => <Text>{item.title}</Text>} /> </View> );}Шаг 5 (доп.). Набросок offline-first: очередь и синхронизация
Идея: при save мгновенно пишем в локальный источник, а операцию для сервера кладём в очередь (outbox). Когда NetInfo сообщает о появлении сети — «проигрываем» очередь.
type Op = { type: "save" | "remove"; payload: any };let queue: Op[] = []; // в реальном проекте — в AsyncStorage/SQLite
export const Outbox = { enqueue(op: Op) { queue.push(op); },
// sync вызывается при восстановлении сети async sync(remote: RemoteNoteSource) { for (const op of [...queue]) { if (op.type === "save") await remote.push(op.payload); queue = queue.slice(1); // удаляем успешно применённую операцию } },};
// presentation: подписка на сетьimport NetInfo from "@react-native-community/netinfo";NetInfo.addEventListener((state) => { if (state.isConnected) Outbox.sync(new RemoteNoteSource());});Конфликты разрешаем по updatedAt (Last-Write-Wins): побеждает запись с большей меткой времени. Это соответствует выбору AP и итоговой согласованности из лекции.
Что сдать
- Код всех файлов из шагов 1–4 (рабочий проект на Expo).
- Скриншот/видео: запуск с пустым кешем (идёт сеть) и повторный запуск (Cache-First отдаёт мгновенно).
- Короткий ответ (3–5 предложений): почему интерфейс репозитория лежит в домене и какую политику вы выбрали для экрана списка и почему.
- (Доп.) Демонстрация очереди: сохранение в офлайне и отправка после «появления сети».
Критерии оценки
| Критерий | Вес |
|---|---|
Доменная сущность и интерфейс репозитория вынесены в domain, без импортов сети/хранилища | 15% |
| Два рабочих источника (remote-mock и local/AsyncStorage) | 15% |
| Репозиторий-фасад реализует интерфейс и инкапсулирует выбор источника | 20% |
| Корректно работают обе политики: Cache-First и Network-First (с fallback) | 20% |
| UI/хук зависят только от интерфейса репозитория (источники не импортируются в presentation) | 15% |
| Доп.: очередь изменений и синхронизация при появлении сети | 10% |
| Оформление, README, ответы на вопросы из «Что сдать» | 5% |
Вопросы для самопроверки
- Почему интерфейс
NoteRepositoryобъявлен вdomain, аCachedNoteRepository— вdata? Как это связано с Dependency Rule? - Чем отличается поведение Cache-First и Network-First при недоступной сети? Какие данные увидит пользователь в каждом случае?
- Для какого экрана вы бы выбрали Network-First, а для какого — Cache-First? Приведите пример.
- Что произойдёт с UI, если заменить AsyncStorage на SQLite? Какие файлы придётся менять?
- Зачем нужна очередь (outbox) и почему операциям нужны стабильные
idи меткаupdatedAt? - Почему мобильный клиент обычно выбирает доступность (AP) и итоговую согласованность, а не строгую согласованность?
Ресурсы
- Лекция 10 «Clean Architecture и слой данных» (этого курса).
- Expo AsyncStorage — https://docs.expo.dev/versions/latest/sdk/async-storage/
- NetInfo (определение состояния сети) — https://github.com/react-native-netinfo/netinfo
- TanStack Query (готовый stale-while-revalidate) — https://tanstack.com/query/latest
- R. Martin. The Clean Architecture — https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html