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+cachedAtrequired- Column name = domain entity field name
nullablecolumns 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+propsgetter
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)),
);
}
}