LogoSkills

client-cache Reference

---

Client Cache Architecture - Detailed Code Templates#


1. Drift Tables#

Simple List (List<Entity>)#

@DataClassName('ScheduleMemoItemData')
class ScheduleMemoItems extends Table {
  TextColumn get cacheKey => text()();          // Required
  TextColumn get id => text()();                // ScheduleMemo.id
  DateTimeColumn get date => dateTime()();      // ScheduleMemo.date
  TextColumn get content => text()();           // ScheduleMemo.content
  DateTimeColumn get cachedAt => dateTime()();  // Required
  // No primaryKey (use rowid)
}

Nested Structure (List<Parent(field, items: List<Child>)>)#

Includes parent entity fields as columns in each row. Re-groups by parent fields on restoration.

@DataClassName('ScheduleItemData')
class ScheduleItems extends Table {
  TextColumn get cacheKey => text()();                    // Required
  DateTimeColumn get date => dateTime()();                // โ† ๋ถ€๋ชจ(ScheduleDayData.date)
  TextColumn get id => text()();                          // ScheduleItem.id
  TextColumn get title => text()();                       // ScheduleItem.title
  DateTimeColumn get startDateTime => dateTime()();
  DateTimeColumn get endDateTime => dateTime()();
  DateTimeColumn get cachedAt => dateTime()();            // Required
  // No primaryKey (use rowid)
}

Rules Summary:

  • Row tables only (JSON blob prohibited)
  • No primaryKey (use rowid)
  • cacheKey + cachedAt required
  • Column name = domain entity field name
  • nullable columns only when actually nullable
  • Separate file per table

2. DAO#

DAO handles Drift CRUD only. Does not depend on domain entities, handles only Companions.

import 'package:drift/drift.dart';
import 'package:{feature}/src/data/local/{feature}_database.dart';
import 'package:{feature}/src/data/local/tables/{table}.dart';

part '{feature}_{entity}_dao.g.dart';

@DriftAccessor(tables: [ScheduleItems])
class ScheduleItemDao extends DatabaseAccessor<ScheduleDatabase>
    with _$ScheduleItemDaoMixin {
  ScheduleItemDao(super.db);

  /// Query rows by cacheKey
  Future<List<ScheduleItemData>> getItemsByCacheKey(String cacheKey) =>
      (select(scheduleItems)..where((t) => t.cacheKey.equals(cacheKey))).get();

  /// Watch rows by cacheKey (for SWR)
  Stream<List<ScheduleItemData>> watchItemsByCacheKey(String cacheKey) =>
      (select(scheduleItems)..where((t) => t.cacheKey.equals(cacheKey))).watch();

  /// 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 validity check
  Future<bool> isCacheValid(String cacheKey, Duration maxAge) async {
    final row = await (select(scheduleItems)
          ..where((t) => t.cacheKey.equals(cacheKey))
          ..limit(1))
        .getSingleOrNull();
    if (row == null) return false;
    return DateTime.now().difference(row.cachedAt) < maxAge;
  }

  /// Delete by prefix matching (cache invalidation)
  Future<void> deleteByCacheKeyPrefix(String prefix) =>
      (delete(scheduleItems)
            ..where((t) => t.cacheKey.like('$prefix%')))
          .go();
}

Import rules:

  • Import only drift + database + tables
  • No core import needed (exception: when using core symbols like Either, Failure, use core + hide)

3. DB Migration#

Since it's cache data, DROP existing tables and recreate.

@DriftDatabase(tables: [ScheduleItems, ScheduleMemoItems], daos: [ScheduleItemDao, ScheduleMemoCacheDao])
class ScheduleDatabase extends _$ScheduleDatabase {
  ScheduleDatabase(super.e);

  @override
  int get schemaVersion => 2;

  @override
  MigrationStrategy get migration => MigrationStrategy(
    onCreate: (Migrator migrator) async {
      await migrator.createAll();
    },
    onUpgrade: (Migrator migrator, int from, int to) async {
      for (final table in allTables) {
        await migrator.deleteTable(table.actualTableName);
        await migrator.createTable(table);
      }
    },
  );
}

4. CacheRepository#

CacheRepository implements ICacheRepository and handles DB Data <-> domain entity conversion.

class ScheduleDataCacheRepository
    implements ICacheRepository<List<ScheduleDayData>> {
  const ScheduleDataCacheRepository({
    required ScheduleItemDao scheduleItemDao,
  }) : _dao = scheduleItemDao;

  final ScheduleItemDao _dao;

  @override
  Future<Either<Failure, List<ScheduleDayData>?>> getFromCache(
    CacheQuery query,
  ) async {
    try {
      final rows = await _dao.getItemsByCacheKey(query.cacheKey);
      if (rows.isEmpty) return const Right(null);
      return Right(_groupByDate(rows));
    } on Exception catch (error, stackTrace) {
      return Left(CacheFailure(error.toString(), stackTrace: stackTrace));
    }
  }

  @override
  Stream<Either<Failure, List<ScheduleDayData>?>> watchFromCache(
    CacheQuery query,
  ) =>
      _dao.watchItemsByCacheKey(query.cacheKey).map((rows) {
        if (rows.isEmpty) return const Right(null);
        return Right(_groupByDate(rows));
      });

  @override
  Future<bool> isCacheValid(CacheQuery query, Duration maxAge) =>
      _dao.isCacheValid(query.cacheKey, maxAge);

  @override
  Future<Either<Failure, void>> saveToCache(
    List<ScheduleDayData> data,
    CacheQuery query,
  ) async {
    try {
      final companions = data
          .expand(
            (day) => day.items.map(
              (item) => _toCompanion(item, day.date, query.cacheKey),
            ),
          )
          .toList();
      await _dao.saveItems(companions, query.cacheKey);
      return const Right(null);
    } on Exception catch (error, stackTrace) {
      return Left(CacheFailure(error.toString(), stackTrace: stackTrace));
    }
  }

  // --- Conversion methods ---

  /// DB Data -> Domain entity
  static ScheduleItem _toEntity(ScheduleItemData data) => ScheduleItem(
        id: data.id,
        title: data.title,
        startDateTime: data.startDateTime,
        endDateTime: data.endDateTime,
      );

  /// Domain entity -> Companion
  static ScheduleItemsCompanion _toCompanion(
    ScheduleItem entity,
    DateTime parentDate,
    String cacheKey,
  ) =>
      ScheduleItemsCompanion(
        cacheKey: Value(cacheKey),
        date: Value(parentDate),
        id: Value(entity.id),
        title: Value(entity.title),
        startDateTime: Value(entity.startDateTime),
        endDateTime: Value(entity.endDateTime),
        cachedAt: Value(DateTime.now()),
      );

  /// Nested structure re-grouping
  List<ScheduleDayData> _groupByDate(List<ScheduleItemData> rows) {
    final grouped = <DateTime, List<ScheduleItem>>{};
    for (final row in rows) {
      grouped.putIfAbsent(row.date, () => []).add(_toEntity(row));
    }
    return grouped.entries
        .map((e) => ScheduleDayData(date: e.key, items: e.value))
        .toList()
      ..sort((a, b) => a.date.compareTo(b.date));
  }
}

5. NetworkRepository#

class ScheduleDataNetworkRepository
    implements INetworkRepository<List<ScheduleDayData>> {
  const ScheduleDataNetworkRepository({
    required OpenApiService openApiService,
    // Additional parameters (needed for network requests)
  }) : _openApiService = openApiService;

  final OpenApiService _openApiService;

  @override
  Future<Either<Failure, List<ScheduleDayData>>> fetchFromNetwork(
    CacheQuery query,
  ) async {
    try {
      final q = query as ScheduleDataCacheQuery;
      final response = await _openApiService.calendarApi.getScheduleData(
        startDate: q.startDate,
        endDate: q.endDate,
        classId: q.classId,
      );
      return Right(ScheduleMapper.fromResponse(response));
    } on Exception catch (error, stackTrace) {
      return Left(ScheduleMapper.mapException(error, stackTrace));
    }
  }
}

6. Repository Mixin#

GET List + SWR#

mixin ScheduleOpenApiMixin implements IScheduleRepository {
  OpenApiService get openApiService;
  ScheduleItemDao get scheduleItemDao;
  ScheduleMemoCacheDao get memoCacheDao;

  @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,
        ),
      );
}

GET Single + CacheFirst#

@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),
    );

POST/PUT/DELETE + Cache Invalidation#

@override
Future<Either<Failure, ScheduleMemo>> upsertMemo({
  required DateTime date,
  required String content,
}) async {
  try {
    final response = await openApiService.calendarMemosApi.upsertMemo(
      date: date,
      content: content,
    );
    // Invalidate related caches
    await memoCacheDao.deleteByCacheKeyPrefix('schedule__get_memo');
    await memoCacheDao.deleteByCacheKeyPrefix('schedule__get_memos');
    return Right(ScheduleMapper.fromCalendarMemoResponse(response));
  } on Exception catch (error, stackTrace) {
    return Left(ScheduleMapper.mapException(error, stackTrace));
  }
}

7. UseCase#

SWR UseCase (Stream Return)#

SWR UseCase returns Stream, so it does not implement the UseCase interface.

class GetScheduleDataUseCase {
  const GetScheduleDataUseCase();
  IScheduleRepository get _repository => getIt();

  Stream<Either<Failure, List<ScheduleDayData>>> call(
    GetScheduleDataParams params,
  ) => _repository.getScheduleData(
    startDate: params.startDate,
    endDate: params.endDate,
    classId: params.classId,
  );
}

class GetScheduleDataParams extends Equatable {
  const GetScheduleDataParams({
    required this.startDate,
    required this.endDate,
    this.classId,
  });
  final DateTime startDate;
  final DateTime endDate;
  final String? classId;
  @override
  List<Object?> get props => [startDate, endDate, classId];
}

Future UseCase (CacheFirst/NetworkFirst/Write)#

class GetMemoUseCase
    implements UseCase<ScheduleMemo?, GetMemoParams, IScheduleRepository> {
  const GetMemoUseCase();

  @override
  IScheduleRepository get repo => getIt();

  @override
  Future<Either<Failure, ScheduleMemo?>> call(GetMemoParams params) async {
    try {
      return await repo.getMemo(date: params.date);
    } on Exception catch (error, stackTrace) {
      Log.e('GetMemoUseCase error', error: error, stackTrace: stackTrace);
      return left(UnexpectedFailure(error.toString()));
    }
  }
}

class GetMemoParams extends Equatable {
  const GetMemoParams({required this.date});
  final DateTime date;
  @override
  List<Object?> get props => [date];
}

Params Rules:

  • Naming: {UseCaseClassName}Params
  • Define at the bottom of the same file
  • extends Equatable + props getter

8. Complete BLoC Structure#

class ScheduleBloc extends Bloc<ScheduleEvent, ScheduleState> {
  ScheduleBloc() : super(const ScheduleState()) {
    on<_LoadData>(_onLoadData, transformer: restartable());   // SWR
    on<_MemoSaved>(_onMemoSaved, transformer: droppable());   // ์“ฐ๊ธฐ
    on<_DetailLoaded>(_onDetailLoaded);                        // ๋‹จ์ผ ์กฐํšŒ
  }

  // SWR -> emit.forEach + Params + onError required
  Future<void> _onLoadData(_LoadData event, Emitter<ScheduleState> emit) async {
    await emit.forEach(
      const GetScheduleDataUseCase().call(
        GetScheduleDataParams(
          startDate: event.startDate,
          endDate: event.endDate,
        ),
      ),
      onData: (result) => result.fold(
        (failure) => state.copyWith(status: Status.failure),
        (data)    => state.copyWith(status: Status.success, data: data),
      ),
      onError: (_, __) => state.copyWith(status: Status.failure),
    );
  }

  // Write -> await + isClosed + droppable + Params
  Future<void> _onMemoSaved(
    _MemoSaved event,
    Emitter<ScheduleState> emit,
  ) async {
    emit(state.copyWith(status: Status.loading));
    final result = await const UpsertMemoUseCase().call(
      UpsertMemoParams(date: event.date, content: event.content),
    );
    if (isClosed) return;
    result.fold(
      (failure) => emit(state.copyWith(status: Status.failure)),
      (memo)    => emit(state.copyWith(status: Status.success, memo: memo)),
    );
  }

  // Single query (CacheFirst/NetworkFirst)
  Future<void> _onDetailLoaded(
    _DetailLoaded event,
    Emitter<ScheduleState> emit,
  ) async {
    emit(state.copyWith(detailStatus: Status.loading));
    final result = await const GetMemoUseCase().call(
      GetMemoParams(date: event.date),
    );
    if (isClosed) return;
    result.fold(
      (failure) => emit(state.copyWith(detailStatus: Status.failure)),
      (memo)    => emit(state.copyWith(detailStatus: Status.success, memo: memo)),
    );
  }
}