Client cache architecture rules for this project.
Refer to the /client-cache skill for detailed implementation guide.
3 Cache Strategies#
| Strategy | UseCase Return Type | Entry Point | Selection Criteria |
|---|---|---|---|
| SWR | Stream<Either<Failure, T>> |
watchStream() |
GET lists, real-time, externally mutable |
| CacheFirst | Future<Either<Failure, T>> |
execute() |
GET single, offline first |
| NetworkFirst | Future<Either<Failure, T>> |
execute() |
Payment/auth, always need latest |
Automatic Strategy Selection Rules#
GET ๋ชฉ๋ก โ SWR โ Stream < Either < Failure, List < T > > >
GET ๋จ์ผ โ CacheFirst or NetworkFirst โ Future < Either < Failure, T > >
POST/PUT/DELETE โ ์ง์ ํธ์ถ (์บ์ ์์) + ๊ด๋ จ ์บ์ ๋ฌดํจํ
BlocSignal Transformer Rules#
Transformers are built into bloc_signals โ do not import bloc_concurrency.
| Event Type | transformer | Reason |
|---|---|---|
| SWR Stream subscription | restartable() |
Discard emissions from the superseded execution |
| Write operations (POST/PUT/DELETE) | droppable() |
Prevent duplicate submissions |
| Transactional ordering | sequential() |
FIFO via Mutex |
| Independent parallel requests | (omit) | Default behavior |
โ ๏ธ
bloc_signals'restartable()discards state emissions from superseded executions โ unlikepackage:bloc, it does not cancel the handler's stream subscription. Subscription cleanup is your responsibility (see below).concurrent()does not exist; omitting the transformer is the default.
// โ
CORRECT: Apply restartable to SWR events
on<ItemsLoad>(_onLoad, transformer: restartable());
// โ
CORRECT: Apply droppable to write events
on<ItemCreate>(_onCreate, transformer: droppable());
SWR Stream Subscription Rules#
โ ๏ธ
emit.forEachdoes not exist inbloc_signalsโ it was apackage:blocEmitterAPI. The previous rule ("manual StreamSubscription management prohibited") is inverted: an explicitly owned subscription is now the required pattern, because nothing cleans it up automatically.
When integrating with an SWR UseCase (call() returns Stream), hold the subscription on the
container and cancel it in close(). An onError handler is still required.
// โ
CORRECT: container-owned subscription + onError + close() cleanup
StreamSubscription<Either<Failure, List<Item>>>? _subscription;
Future<void> _onLoad(ItemsLoad event, void Function(ItemsState) emit) async {
await _subscription?.cancel(); // re-subscribe cleanly
_subscription = const GetItemsUseCase().call(GetItemsParams(...)).listen(
(result) {
if (isClosed) return; // close() does not cancel in-flight work
emit(result.fold(
(failure) => stateValue.copyWith(
status: LoadingStatus.error,
failure: failure,
),
(items) => stateValue.copyWith(
status: LoadingStatus.loaded,
items: items,
),
));
},
onError: (Object _, StackTrace __) {
if (isClosed) return;
emit(stateValue.copyWith(status: LoadingStatus.error));
},
);
}
@override
Future<void> close() async {
if (isClosed) return;
await _subscription?.cancel(); // โ ๏ธ required โ no automatic cleanup
await super.close();
}
// โ WRONG: emit.forEach โ does not exist in bloc_signals
// โ WRONG: relying on restartable() to cancel the subscription (it only drops emissions)
// โ WRONG: omitting the close() override โ leaked subscription
// โ WRONG: .last loses SWR benefits (ignores cache)
final result = await useCase.call(params).last;
// โ WRONG: .first ignores server refresh (receives cache only)
final result = await useCase.call(params).first;
Anti-pattern details: See
swr-pattern.mdยง8
cacheKey Format#
' {domain}__{usecase}__key:value '
- Separator: double underscore (
__) -
Pagination parameters (
page,size,limit,offset) not included - Filter parameters are nullable, included only when
!= null - Sort/order parameters must be included โ omitting them returns the previous sort's cache on sort change (#6877/#6924)
-
User/permission scope segment (e.g.
pub:{publisherId}) when the response varies by requester โ a shared key leaks another scope's data (#7893) - Date: Use
CacheQuery.fmtDate()(yyyy-MM-dd)
Example:
'schedule__get_schedule_data__start:2026-03-01__end:2026-03-31__class:5566'
'homework__get_homework_list__classId:42'
TTL (Cache Expiration Time)#
| Policy | TTL | Suitable For |
|---|---|---|
CachePolicies.realtime | 5 min | Notifications, attendance |
CachePolicies.standard | 10 min | Homework, schedule (default) |
CachePolicies.stable | 1 hour | Settings, products |
CachePolicies.immutable | 24 hours | Notices, terms |
Drift Table Rules#
Requirements#
- Row tables only (JSON blob prohibited)
- Do not set
primaryKey(use rowid) cacheKey+cachedAtcolumns required- Column names must match domain entity field names
- For nested structures, include parent entity fields as columns in each row
// โ
CORRECT: ํ ํ
์ด๋ธ, primaryKey ์์
class HomeworkItems extends Table {
TextColumn get cacheKey => text()();
TextColumn get homeworkId => text()();
TextColumn get title => text()();
DateTimeColumn get cachedAt => dateTime()();
// No primaryKey (use rowid)
}
// โ WRONG: JSON blob
class HomeworkDetailItems extends Table {
TextColumn get cacheKey => text()();
TextColumn get json => text()(); // JSON ๊ธ์ง
@override
Set<Column> get primaryKey => {cacheKey}; // primaryKey ์ค์ ๊ธ์ง
}
upsert prohibited -> DELETE + INSERT#
// โ
CORRECT: DELETE then INSERT
Future<void> saveItems(List<XxxCompanion> companions, String cacheKey) async {
await (delete(table)..where((t) => t.cacheKey.equals(cacheKey))).go();
await batch((b) => b.insertAll(table, companions));
}
// โ WRONG: upsert prohibited
await into(table).insertOnConflictUpdate(companion);
DB Migration: deleteTable + createTable#
onUpgrade: (Migrator migrator, int from, int to) async {
for (final table in allTables) {
await migrator.deleteTable(table.actualTableName);
await migrator.createTable(table);
}
},
DAO Rules#
- Handle Drift CRUD only (no domain entity dependency)
- Accept only Companion for INSERT
- Entity <-> Companion conversion is CacheRepository's responsibility
CacheRepository Conversion Methods#
// โ
CORRECT
static XxxEntity _toEntity(XxxItemData data) { ... }
static XxxCompanion _toCompanion(XxxEntity entity, String cacheKey) { ... }
// โ WRONG
static XxxEntity _dataToXxx(XxxItemData data) { ... }
UseCase Rules#
- All UseCases use Params class (when parameters exist)
- SWR UseCase: Does not implement UseCase interface,
_repositorygetter, returns Stream -
Future UseCase:
implements UseCase,repogetter, try-catch +Log.e - Unified OpenApiService (OpenApiClient prohibited)
Cache Invalidation After Write#
Delete related caches by prefix after POST/PUT/DELETE completion.
await dao.deleteByCacheKeyPrefix('schedule__get_memo');
await dao.deleteByCacheKeyPrefix('schedule__get_memos');
Structured alternative (kobic, Epic #6021): prefer repository-level
MutationRepositoryMixin.executeMutation(invalidatedPrefixes:)so invalidation cannot be forgotten per-mutation, and invalidation failures never mask an already-successful server mutation โ see swr-pattern skill ยง15.LIKE wildcard caveat (#8334): implement
deleteByCacheKeyPrefixwith a string range comparison (>= prefix AND < prefix + '\uffff'), notlike('$prefix%')โ_in cache keys is a single-char LIKE wildcard, somy_page__would also deletemyxpage__โฆkeys.
Related Documents#
- Client Cache Skill - Detailed implementation guide
- Caching Patterns Detail
- BLoC Patterns