LogoSkills

Caching Strategy Patterns

Optimizes user experience and network efficiency through data caching strategies.

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#

StrategyEntry PointReturn TypeSuitable 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#

SWRCacheFirstNetworkFirst
Feed, timelineUser profilePayment status
Notification listApp settingsAuth token
Schedule, homework listCategory listReal-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, _toCompanion conversion 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-level MutationRepositoryMixin.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