Reference location:
.claude/references/patterns/caching-patterns.md
Optimizes user experience and network efficiency through data caching strategies.
Refer to the /client-cache skill for detailed implementation guide.
Strategy Comparison#
| Strategy | Entry Point | Return Type | Suitable For |
|---|---|---|---|
| SWR | watchStream() |
Stream<Either<Failure, T>> |
GET lists, externally mutable |
| CacheFirst | execute() |
Future<Either<Failure, T>> |
GET single, offline first |
| NetworkFirst | execute() |
Future<Either<Failure, T>> |
Payment/auth, always need latest |
SWR Pattern (Stale-While-Revalidate)#
Returns cached data immediately and refreshes in the background.
Repository Interface#
// domain/repository/i_schedule_repository.dart
abstract interface class IScheduleRepository {
/// Returns Stream using SWR strategy
Stream<Either<Failure, List<ScheduleDayData>>> getScheduleData({
required DateTime startDate,
required DateTime endDate,
String? classId,
});
}
Repository Mixin (Strategy Assembly)#
mixin ScheduleOpenApiMixin implements IScheduleRepository {
OpenApiService get openApiService;
ScheduleItemDao get scheduleItemDao;
@override
Stream<Either<Failure, List<ScheduleDayData>>> getScheduleData({
required DateTime startDate,
required DateTime endDate,
String? classId,
}) =>
SwrStrategyImpl<List<ScheduleDayData>>(
cacheRepository: ScheduleDataCacheRepository(
scheduleItemDao: scheduleItemDao,
),
networkRepository: ScheduleDataNetworkRepository(
openApiService: openApiService,
),
policy: CachePolicies.standard,
).watchStream(
ScheduleDataCacheQuery(
startDate: startDate,
endDate: endDate,
classId: classId,
),
);
}
BlocSignal Integration: ์ปจํ ์ด๋ ์์ ๊ตฌ๋ + restartable()#
โ ๏ธ
emit.forEach๋package:bloc์ API ๋กbloc_signals์ ์์ต๋๋ค. ๊ตฌ๋ ์ ์ปจํ ์ด๋๊ฐ ๋ค๊ณclose()์์ ์ง์ ํด์ ํฉ๋๋ค.
class ScheduleBloc extends BlocSignal<ScheduleEvent, ScheduleState> {
ScheduleBloc() : super(initialState: const ScheduleState()) {
on<_LoadData>(_onLoadData, transformer: restartable());
}
StreamSubscription<Either<Failure, ScheduleData>>? _dataSub;
Future<void> _onLoadData(
_LoadData event,
void Function(ScheduleState) emit,
) async {
await _dataSub?.cancel();
_dataSub = const GetScheduleDataUseCase().call(
GetScheduleDataParams(
startDate: event.startDate,
endDate: event.endDate,
),
).listen(
(result) {
if (isClosed) return;
emit(result.fold(
(failure) => stateValue.copyWith(status: Status.failure),
(data) => stateValue.copyWith(status: Status.success, data: data),
));
},
onError: (Object _, StackTrace __) {
if (isClosed) return;
emit(stateValue.copyWith(status: Status.failure));
},
);
}
@override
Future<void> close() async {
if (isClosed) return;
await _dataSub?.cancel(); // โ ๏ธ ์๋ ์ ๋ฆฌ ์์
await super.close();
}
}
How restartable() Works
1. add(LoadData(start: mar1)) โ ์คํธ๋ฆผA ๊ตฌ๋
(์บ์ โ ์๋ฒ ์์ฐจ emit)
2. add(LoadData(start: apr1)) โ ํธ๋ค๋ฌ๊ฐ _dataSub.cancel() ๋ก ์คํธ๋ฆผA ํด์
โ ์คํธ๋ฆผB ๊ตฌ๋
์์
โ restartable() ์ ํธ๋ค๋ฌ1 ์ ๋ฆ์ emission ์ ํ๊ธฐ
โ ๏ธ bloc_signals ์ restartable() ์ superseded ์คํ์ emission ๋ง ๋ฒ๋ฆฌ๊ณ
์คํธ๋ฆผ ๊ตฌ๋
์
์ทจ์ํ์ง ์์ต๋๋ค. ๋ฐ๋ผ์ StreamSubscription ์ ์ปจํ
์ด๋ ํ๋๋ก ๋ค๊ณ close() ์ค๋ฒ๋ผ์ด๋์์
cancel() ํ๋ ๊ฒ์ด ํ์์
๋๋ค โ ์๋ ์ ๋ฆฌ๋ ์์ต๋๋ค.
์์ธ: swr-pattern
CacheFirst Pattern#
Returns immediately without network call if cache is valid. Makes network call if expired or missing.
Repository Mixin#
@override
Future<Either<Failure, ScheduleMemo?>> getMemo({
required DateTime date,
}) =>
CacheFirstStrategyImpl<ScheduleMemo?>(
cacheRepository: ScheduleMemoCacheRepository(
memoCacheDao: memoCacheDao,
),
networkRepository: ScheduleMemoNetworkRepository(
openApiService: openApiService,
),
policy: CachePolicies.standard,
).execute(
ScheduleMemoCacheQuery(date: date),
);
BLoC Integration#
Future<void> _onDetailLoaded(
_DetailLoaded event,
void Function(ScheduleState) emit,
) async {
emit(stateValue.copyWith(detailStatus: Status.loading));
final result = await const GetMemoUseCase().call(
GetMemoParams(date: event.date),
);
if (isClosed) return; // Required
result.fold(
(failure) => emit(stateValue.copyWith(detailStatus: Status.failure)),
(memo) => emit(stateValue.copyWith(detailStatus: Status.success, memo: memo)),
);
}
NetworkFirst Pattern#
Makes network call first and falls back to cache on failure.
Repository Mixin#
@override
Future<Either<Failure, PaymentStatus>> getPaymentStatus({
required String orderId,
}) =>
NetworkFirstStrategyImpl<PaymentStatus>(
cacheRepository: PaymentStatusCacheRepository(
paymentStatusDao: paymentStatusDao,
),
networkRepository: PaymentStatusNetworkRepository(
openApiService: openApiService,
),
policy: CachePolicies.realtime,
).execute(
PaymentStatusCacheQuery(orderId: orderId),
);
Local Cache Implementation (Drift)#
Tables (Row tables only, JSON blob prohibited)#
@DataClassName('ScheduleItemData')
class ScheduleItems extends Table {
TextColumn get cacheKey => text()(); // Required
DateTimeColumn get date => dateTime()(); // Parent field
TextColumn get id => text()();
TextColumn get title => text()();
DateTimeColumn get cachedAt => dateTime()(); // Required
// No primaryKey (use rowid)
}
DAO (Drift CRUD only, no domain entity dependency)#
// DELETE(cacheKey) + INSERT (upsert prohibited)
Future<void> saveItems(
List<ScheduleItemsCompanion> companions,
String cacheKey,
) async {
await (delete(scheduleItems)
..where((t) => t.cacheKey.equals(cacheKey)))
.go();
await batch((b) => b.insertAll(scheduleItems, companions));
}
// Cache invalidation (prefix matching)
// โ ๏ธ Do NOT use like('$prefix%') โ `_` in cache keys is a single-char LIKE
// wildcard, so 'my_page__' would also delete 'myxpage__โฆ' keys (#8334).
// Use a string range comparison for exact prefix semantics:
Future<void> deleteByCacheKeyPrefix(String prefix) =>
(delete(scheduleItems)..where(
(t) =>
t.cacheKey.isBiggerOrEqualValue(prefix) &
t.cacheKey.isSmallerThanValue('$prefix\uffff'),
))
.go();
Selection Guide#
HTTP Method?
โโโ GET
โ โโโ Response is list โ SWR
โ โโโ Response is single
โ โโโ Offline first โ CacheFirst
โ โโโ Always latest โ NetworkFirst
โโโ POST/PUT/DELETE
โ Direct call + deleteByCacheKeyPrefix()
Suitable Cases by Strategy#
| SWR | CacheFirst | NetworkFirst |
|---|---|---|
| Feed, timeline | User profile | Payment status |
| Notification list | App settings | Auth token |
| Schedule, homework list | Category list | Real-time balance |
Checklist#
- Select caching strategy (SWR / CacheFirst / NetworkFirst)
- Drift table: row table, cacheKey + cachedAt, no primaryKey
- DAO: Drift CRUD only (no domain entity dependency, Companion only)
- CacheQuery: 1:1 mapping with UseCase
-
CacheRepository:
_toEntity,_toCompanionconversion methods - NetworkRepository: Use OpenApiService
-
BlocSignal SWR: ์ปจํ
์ด๋ ์์
StreamSubscription+restartable()+onError+close()cancel -
BLoC write:
await+isClosed+droppable() -
Invalidate related caches after write with
deleteByCacheKeyPrefix()โ or, preferred, repository-levelMutationRepositoryMixin.executeMutation(invalidatedPrefixes:)(swr-pattern skill ยง15, Epic #6021) - cacheKey includes sort/order parameters (#6877) and a user/permission scope segment when the response varies by requester (#7893)
Referencing Agents#
/cc-flutter:feature:data- Data Layer caching implementation/feature:bloc- Stream-integrated BLoC/client-cache- Detailed implementation guide skill