SWR (Stale-While-Revalidate) Pattern
SWR strategy implementation guide for fast UI responsiveness when fetching data in projects.
SWR strategy implementation guide for fast UI responsiveness when fetching data in projects.
Overview#
The SWR strategy handles two scenarios:
| Scenario | Behavior |
|---|---|
| No cache | Show loading state -> Server request -> Display data |
| Cache exists | Display cached data immediately -> Background server request -> Refresh data |
⚠️ This rule file predates the swr-pattern skill; on conflict the skill wins. In particular, every re-subscribe path below needs the per-key generation token of skill §9-7 (unibook #13475 / PR #13478) —
await _sub?.cancel();alone leaks a subscription when two calls of the same handler overlap.
Role by Architecture Layer#
┌─────────────────────────────────────────────────────────────────┐
│ Page (Widget) │
│ - BlocSignalBuilder로 상태 렌더링 │
│ - 로딩/에러/데이터 상태 처리 │
└──────────────────────────┬──────────────────────────────────────┘
│ add(Event)
▼
┌─────────────────────────────────────────────────────────────────┐
│ BLoC │
│ - Stream 구독 (컨테이너 소유 StreamSubscription) │
│ - 캐시 → emit → 서버 → emit (지연 응답 처리) │
│ - isClosed 체크 필수 │
└──────────────────────────┬──────────────────────────────────────┘
│ call(params)
▼
┌─────────────────────────────────────────────────────────────────┐
│ UseCase (Stream 기반) │
│ - Stream<Either<Failure, T>> 반환 │
│ - async* + yield로 순차 데이터 방출 │
└──────────────────────────┬──────────────────────────────────────┘
│ getWithCache(params)
▼
┌─────────────────────────────────────────────────────────────────┐
│ Repository │
│ - 캐시 조회 → yield (캐시 데이터) │
│ - 서버 요청 → yield (서버 데이터) │
│ - 캐시 갱신 │
└─────────────────────────────────────────────────────────────────┘
1. Repository Pattern (Cache + Server Sequential Return)#
Stream-based Repository Method#
// repository_interface.dart
abstract class IBookRepository {
/// SWR pattern: cache -> server sequential return
Stream<Either<Failure, List<Book>>> getBooksWithCache({
required int categoryId,
int? limit,
});
/// Before Future 패턴 (단순 서버 호출)
Future<Either<Failure, List<Book>>> getBooks({
required int categoryId,
int? limit,
});
}
Repository Implementation#
// book_repository.dart
class BookRepository implements IBookRepository {
const BookRepository(this._client, this._cacheService);
final ApiClient _client;
final BookCacheService _cacheService;
@override
Stream<Either<Failure, List<Book>>> getBooksWithCache({
required int categoryId,
int? limit,
}) async* {
final cacheKey = 'books_$categoryId';
// 1. Check and emit cached data (if available)
final cachedBooks = _cacheService.get(cacheKey);
if (cachedBooks != null && cachedBooks.isNotEmpty) {
yield right(cachedBooks);
}
// 2. Server request
try {
final serverBooks = await _client.book.getBooks(
categoryId: categoryId,
limit: limit,
);
// 3. Cache refresh
_cacheService.set(cacheKey, serverBooks);
// 4. Emit server data
yield right(serverBooks);
} on Exception catch (error, stackTrace) {
Log.e('도서 목록 조회 실패', error: error, stackTrace: stackTrace);
// Ignore error if cache existed (cached data already displayed)
if (cachedBooks == null || cachedBooks.isEmpty) {
yield left(BookLoadFailure(message: error.toString()));
}
}
}
}
2. UseCase Pattern (Stream-based)#
StreamUseCase Abstract Class#
// stream_usecase.dart
/// Stream 기반 UseCase 인터페이스 (SWR 패턴용)
///
/// 캐시 데이터와 서버 데이터를 순차적으로 방출합니다.
abstract class StreamUseCase<T, Params, Repo> {
/// Repository instance
Repo get repo;
/// Execute UseCase (returns Stream)
///
/// On success, sequentially emits data of type [T],
/// on failure, emits error of type [Failure].
Stream<Either<Failure, T>> call(Params param);
}
/// Stream UseCase requiring authentication
abstract class AuthRequiredStreamUseCase<T, Params, Repo>
extends StreamUseCase<T, Params, Repo>
with GlobalAuthMixin {
String get featureName;
Stream<Either<Failure, T>> executeBusinessLogic(Params param);
@override
Stream<Either<Failure, T>> call(Params param) async* {
// Auth check
final isAuthenticated = await checkAuthentication();
if (!isAuthenticated) {
yield left(AuthenticationFailure(message: '$featureName: 인증이 필요합니다.'));
return;
}
// Execute business logic (Stream)
await for (final result in executeBusinessLogic(param)) {
yield result;
}
}
}
UseCase Implementation Example#
// get_books_with_cache_usecase.dart
class GetBooksWithCacheUseCase
implements StreamUseCase<List<Book>, GetBooksParams, IBookRepository> {
const GetBooksWithCacheUseCase();
@override
IBookRepository get repo => getIt<IBookRepository>();
@override
Stream<Either<Failure, List<Book>>> call(GetBooksParams params) {
return repo.getBooksWithCache(
categoryId: params.categoryId,
limit: params.limit,
);
}
}
3. BlocSignal Pattern (Stream Subscription)#
⚠️
emit.forEachdoes not exist inbloc_signals— it was apackage:blocEmitterAPI. Own the subscription on the container and cancel it inclose().
Container-owned StreamSubscription + generation token (Recommended)#
// book_bloc.dart
class BookBloc extends BlocSignal<BookEvent, BookState> {
BookBloc(
this._getBooksWithCacheUseCase, {
BookState initialState = const BookState(),
}) : super(initialState: initialState) {
// No `transformer:` — `restartable()` gates emissions only and cancels
// nothing, and its token lives on the `on<E>` registration (see §8).
on<_LoadBooks>(_onLoadBooks);
}
final GetBooksWithCacheUseCase _getBooksWithCacheUseCase;
StreamSubscription<Either<Failure, List<Book>>>? _booksSub;
int _booksGen = 0; // one counter per subscription slot (#13475)
Future<void> _onLoadBooks(
_LoadBooks event,
void Function(BookState) emit,
) async {
// ① Claim the slot before the first await.
final generation = ++_booksGen;
// ✅ Cancel the previous subscription, then re-subscribe
final previous = _booksSub;
_booksSub = null;
await previous?.cancel();
// ② A newer call took the slot while we awaited — step aside, do NOT subscribe.
if (isClosed || generation != _booksGen) return;
// Show loading state only when no cache
if (stateValue.books.isEmpty) {
emit(stateValue.copyWith(status: const BookStatusLoading()));
}
// `late final` is safe here — `onDone` cannot fire before `listen` returns.
late final StreamSubscription<Either<Failure, List<Book>>> subscription;
subscription = _getBooksWithCacheUseCase(
GetBooksParams(categoryId: event.categoryId),
).listen(
(result) {
if (isClosed) return;
emit(result.fold(
(failure) => stateValue.copyWith(
status: BookStatusError(failure.toString()),
),
(books) => stateValue.copyWith(
status: const BookStatusLoaded(),
books: books,
lastUpdated: DateTime.now(),
),
));
},
onError: (Object error, StackTrace stackTrace) {
Log.e('도서 목록 로드 실패', error: error, stackTrace: stackTrace);
if (isClosed) return;
emit(stateValue.copyWith(status: BookStatusError(error.toString())));
},
onDone: () {
// ⑤ Deregister only while the slot still holds THIS subscription.
if (identical(_booksSub, subscription)) _booksSub = null;
},
cancelOnError: false,
);
// ③ `emit()` and `listen()` above are synchronous — they can re-dispatch the
// same event, or close the container. Re-check before registering.
// ⚠️ The canonical `SwrBlocSignalMixin` checks only the generation here;
// `isClosed` is an extra guard this hand-rolled form adds.
if (isClosed || generation != _booksGen) {
await subscription.cancel();
return;
}
// ④ Never overwrite a live subscription.
final stale = _booksSub;
if (stale != null && !identical(stale, subscription)) {
unawaited(stale.cancel()); // dart:async
}
_booksSub = subscription;
}
@override
Future<void> close() async {
if (isClosed) return;
// `super.close()` flips `isClosed` synchronously (bloc_signals 1.2.0
// `cubit_signal_mixin.dart` sets `_isClosed = true` right after its
// re-entrancy guard `if (_isClosed) return;`, and the body has no `await`
// at all), so call it BEFORE awaiting the cancel — otherwise an in-flight
// `_onLoadBooks` resumes with `isClosed == false` and opens a subscription
// nobody cancels.
final subscription = _booksSub;
_booksSub = null;
await super.close();
await subscription?.cancel(); // ⚠️ no automatic cleanup exists
}
}
await for (Alternative — ⚠️ not for never-closing SWR streams)#
SwrStrategyImpl.watchStream() never closes, so an await for loop over it never returns
and the
handler hangs (see §8). Use it only for streams that terminate.
Future<void> _onLoadBooks(
_LoadBooks event,
void Function(BookState) emit,
) async {
if (stateValue.books.isEmpty) {
emit(stateValue.copyWith(status: const BookStatusLoading()));
}
// Traverse Stream with await for (terminating streams only)
await for (final result in _getBooksWithCacheUseCase(
GetBooksParams(categoryId: event.categoryId),
)) {
// ⚠️ Required: isClosed check
if (isClosed) return;
emit(result.fold(
(failure) => state.copyWith(
status: BookStatusError(failure.toString()),
),
(books) => state.copyWith(
status: const BookStatusLoaded(),
books: books,
lastUpdated: DateTime.now(),
),
));
}
}
4. State Design (SWR Compatible)#
State Class#
// book_state.dart
@freezed
class BookState with _$BookState {
const factory BookState({
@Default(BookStatusInitial()) BookStatus status,
@Default([]) List<Book> books,
DateTime? lastUpdated, // 캐시/서버 구분용
}) = _BookState;
}
@freezed
sealed class BookStatus with _$BookStatus {
const factory BookStatus.initial() = BookStatusInitial;
const factory BookStatus.loading() = BookStatusLoading;
const factory BookStatus.loaded() = BookStatusLoaded;
const factory BookStatus.error(String message) = BookStatusError;
const factory BookStatus.refreshing() = BookStatusRefreshing; // ✅ 백그라운드 갱신 in progress
}
Cache/Server Data Distinction State#
// 더 정교한 상태 관리
@freezed
sealed class BookStatus with _$BookStatus {
const factory BookStatus.initial() = BookStatusInitial;
const factory BookStatus.loading() = BookStatusLoading;
const factory BookStatus.cachedData() = BookStatusCachedData; // ✅ 캐시 데이터 표시 in progress
const factory BookStatus.refreshing() = BookStatusRefreshing; // ✅ 백그라운드 갱신 in progress
const factory BookStatus.freshData() = BookStatusFreshData; // ✅ 서버 데이터로 갱신됨
const factory BookStatus.error(String message) = BookStatusError;
}
5. UI Pattern (Page/Widget)#
Using BlocSignalBuilder#
// book_list_page.dart
class BookListPage extends StatelessWidget {
const BookListPage({super.key});
@override
Widget build(BuildContext context) {
return BlocSignalBuilder<BookBloc, BookState>(
buildWhen: (prev, curr) =>
prev.status != curr.status ||
prev.books.length != curr.books.length || // ⚠️ 리스트는 length로 비교
prev.lastUpdated != curr.lastUpdated,
builder: (context, state) {
return switch (state.status) {
BookStatusInitial() ||
BookStatusLoading() when state.books.isEmpty =>
const BookListSkeleton(), // 로딩 스켈레톤
BookStatusCachedData() ||
BookStatusRefreshing() =>
BookListView(
books: state.books,
isRefreshing: state.status is BookStatusRefreshing,
),
BookStatusLoaded() ||
BookStatusFreshData() =>
BookListView(books: state.books),
BookStatusError(:final message) when state.books.isEmpty =>
ErrorView(message: message),
BookStatusError() =>
BookListView(
books: state.books,
errorMessage: (state.status as BookStatusError).message,
),
_ => BookListView(books: state.books),
};
},
);
}
}
Background Refresh Indicator#
class BookListView extends StatelessWidget {
const BookListView({
required this.books,
this.isRefreshing = false,
this.errorMessage,
super.key,
});
final List<Book> books;
final bool isRefreshing;
final String? errorMessage;
@override
Widget build(BuildContext context) {
return Column(
children: [
// 백그라운드 갱신 in progress 표시
if (isRefreshing)
const LinearProgressIndicator(),
// 에러 메시지 (데이터는 있지만 갱신 실패)
if (errorMessage != null)
ErrorBanner(message: errorMessage!),
// 데이터 목록
Expanded(
child: ListView.builder(
itemCount: books.length,
itemBuilder: (context, index) => BookTile(book: books[index]),
),
),
],
);
}
}
6. Cache Service Pattern#
Memory Cache Service#
// book_cache_service.dart
@singleton
class BookCacheService {
final _cache = <String, List<Book>>{};
final _timestamps = <String, DateTime>{};
/// Cache validity duration (5 minutes)
static const _cacheDuration = Duration(minutes: 5);
List<Book>? get(String key) {
final timestamp = _timestamps[key];
if (timestamp == null) return null;
// Cache expiration check
if (DateTime.now().difference(timestamp) > _cacheDuration) {
_cache.remove(key);
_timestamps.remove(key);
return null;
}
return _cache[key];
}
void set(String key, List<Book> books) {
_cache[key] = books;
_timestamps[key] = DateTime.now();
}
void invalidate(String key) {
_cache.remove(key);
_timestamps.remove(key);
}
void invalidateAll() {
_cache.clear();
_timestamps.clear();
}
}
7. Migration Guide#
Converting from Future-based to Stream-based#
// Before: Future 기반
class GetBooksUseCase implements UseCase<List<Book>, GetBooksParams, IBookRepository> {
@override
Future<Either<Failure, List<Book>>> call(GetBooksParams params) async {
return repo.getBooks(categoryId: params.categoryId);
}
}
// After: Stream 기반 (SWR)
class GetBooksWithCacheUseCase
implements StreamUseCase<List<Book>, GetBooksParams, IBookRepository> {
@override
Stream<Either<Failure, List<Book>>> call(GetBooksParams params) {
return repo.getBooksWithCache(categoryId: params.categoryId);
}
}
BLoC Migration#
// Before: Future 기반
Future<void> _onLoadBooks(_LoadBooks event, void Function(BookState) emit) async {
emit(stateValue.copyWith(status: const BookStatusLoading()));
final result = await _getBooksUseCase(params);
if (isClosed) return;
emit(result.fold(
(failure) => state.copyWith(status: BookStatusError(failure.toString())),
(books) => state.copyWith(status: const BookStatusLoaded(), books: books),
));
}
// After: Stream 기반 (SWR)
Future<void> _onLoadBooks(_LoadBooks event, void Function(BookState) emit) async {
if (stateValue.books.isEmpty) {
emit(stateValue.copyWith(status: const BookStatusLoading()));
}
final previous = _booksSub;
_booksSub = null;
await previous?.cancel();
_booksSub = _getBooksWithCacheUseCase(params).listen(
onData: (result) => result.fold(
(failure) => state.copyWith(status: BookStatusError(failure.toString())),
(books) => state.copyWith(
status: const BookStatusLoaded(),
books: books,
lastUpdated: DateTime.now(),
),
),
);
}
⚠️ The "After" snippet is a sketch of the Future→Stream axis only — it omits the generation token. Detaching the slot before the await is already correct here, but that alone still leaks a subscription when two calls overlap — only the generation token closes it. The complete form is §3 above and swr-pattern skill §3 · §9-2 (mechanism: §9-7).
8. Anti-patterns (When Consuming SWR Stream in BLoC)#
❌ .last on SWR Stream — Infinite wait (Critical)#
// ❌ WRONG: SWR Stream is created by SwrStrategyImpl.watchStream() using
// StreamController which is NOT closed until subscription cancel.
// .last awaits Stream close → handler waits forever → state never emits.
final result = await _useCases.getBooks(params).last;
Critical bug: SwrStrategyImpl.watchStream() returns a StreamController.stream
that is closed only via onCancel. Stream.last requires close to return a value, so the handler hangs indefinitely and the page stays in loading state forever. Tests may pass because
Stream.value(...) (single-value) auto-closes — masking the bug.
Real cases:
-
PR #5465 / #5466 —
_onLoadLikedBooks,_onLoadMoreLikedBooks,_onLoadMoreMyBooks도서 목록 미표시 -
PR #5467 —
console_book_list_blocawait for패턴,console_book_registration_bloc4곳await for(handler 미종료)
Recovery test pattern (close되지 않는 stream에서도 동작 검증):
blocSignalTest<MyBloc, MyState>(
'close되지 않는 stream에서도 첫 emission으로 표시되어야 한다',
setUp: () {
final controller = StreamController<Either<Failure, T>>();
addTearDown(controller.close);
controller.add(Right(testData));
when(() => mockUseCase(any())).thenAnswer((_) => controller.stream);
},
// ...
);
⚠️ .first on SWR Stream — Acceptable when cache invalidation is guaranteed#
// ⚠️ Conditional: .first only collects first emission (cache or server)
final result = await _useCases.getUserInfo(const NoParams()).first;
Trade-off: Loses the "background refresh" part of SWR — only the first emission is consumed. The second emission (network result after cache hit) is missed because the stream is no longer awaited.
When .first is acceptable:
-
Cache invalidation is guaranteed by upstream events (e.g.,
DomainEventBus) — first emission becomes a fresh network result on cache miss -
Async transform between emissions is required (e.g.,
BookLike → Bookper item) — a.listencallback cannot await; useStream.asyncMapupstream - Pagination Load More — see Exception below
When .first is wrong:
- Cache TTL is long and no invalidation channel exists → user sees stale data until TTL expires
- A simple list fetch where a plain
.listensubscription works fine
Real cases:
- PR #4585 — 10 calls in
my_page_blocusing.firstlost background refresh -
PR #5465–5467 —
.firstis the correct fix when SWR stream doesn't close (only choice that doesn't hang) -
PR #5471 — Combined with
DomainEventBuscache invalidation,.firstworks correctly because invalidated cache forces fresh fetch on next emission
❌ Direct .listen usage - StreamSubscription management burden#
// ❌ WRONG: Direct listen inside BLoC -> requires subscription management
final subscription = _useCases.getBooks(params).listen(
(result) => add(BooksUpdated(result)),
);
Problem: Missing subscription cancellation on close() causes memory leaks.
⚠️ In
bloc_signalsthere is noemit.forEachand therefore no automatic subscription management. Every.listenmust be stored on the container and cancelled in aclose()override. The anti-pattern is not.listenitself — it is.listenwithoutclose()cleanup, re-subscribing without cancelling the previous subscription, or re-subscribing without a generation token (#13475 — see the note under §8's ✅ pattern).
❌ await for on SWR Stream — Handler hangs#
// ❌ WRONG: SWR Stream doesn't close → await for loop never exits
await for (final result in _useCases.getBooks(params)) {
if (isClosed) return;
result.fold(...);
}
Problem: Same root cause as .last — SwrStrategyImpl.watchStream()
doesn't close. await for loop awaits next emission forever, so the BLoC handler never returns. This causes:
- Memory leaks (subscription never cancelled)
- Next event processing delay (depending on transformer, sequential transformer queues new events)
- Repeated work on every emission (e.g., publisher/category lookup runs twice)
Recovery options:
.firstif cache invalidation is guaranteed (preferred, simplest)-
Container-owned
.listen+ generation token +close()cancellation — the handler returns immediately while the subscription stays alive
Real case: PR #5467 — console_book_list_bloc._loadBooksFromStream, console_book_registration_bloc
4 handlers
✅ Correct pattern — container-owned .listen + generation token#
// ✅ CORRECT: Subscribe to SWR Stream with an owned subscription + generation token
on<_LoadBooks>(_onLoadBooks); // no transformer — see the note below
StreamSubscription<Either<Failure, List<Book>>>? _booksSub;
int _booksGen = 0;
Future<void> _onLoadBooks(_LoadBooks event, void Function(State) emit) async {
final generation = ++_booksGen; // ① claim the slot before any await
final previous = _booksSub;
_booksSub = null;
await previous?.cancel();
if (isClosed || generation != _booksGen) return; // ② a newer run owns the slot
if (stateValue.books.isEmpty) {
emit(stateValue.copyWith(status: const Loading()));
}
late final StreamSubscription<Either<Failure, List<Book>>> subscription;
subscription = _useCases.getBooks(params).listen(
(result) {
if (isClosed) return;
emit(result.fold(
(failure) => stateValue.copyWith(status: Error(failure)),
(books) => stateValue.copyWith(status: const Loaded(), books: books),
));
},
onError: (Object _, StackTrace __) {
if (isClosed) return;
emit(stateValue.copyWith(status: const Error('로드 실패')));
},
onDone: () { // ⑤ only while the slot is still ours
if (identical(_booksSub, subscription)) _booksSub = null;
},
cancelOnError: false,
);
if (isClosed || generation != _booksGen) { // ③ re-check before registering
await subscription.cancel();
return;
}
final stale = _booksSub; // ④ never overwrite a live subscription
if (stale != null && !identical(stale, subscription)) unawaited(stale.cancel());
_booksSub = subscription;
}
@override
Future<void> close() async {
if (isClosed) return;
final subscription = _booksSub;
_booksSub = null;
await super.close(); // flips isClosed synchronously — do this FIRST
await subscription?.cancel();
}
restartable()only discards emissions from the superseded execution — it does not cancel the subscription. And the explicitcancel()above only covers the sequential case:add()invokes the handler synchronously andunawaiteds its Future (bloc_signals 1.2.0bloc_signal_mixin.dart), so two runs can both passawait _booksSub?.cancel()and each open a subscription — the later assignment then overwrites the earlier one without cancelling it, and the orphan keeps feeding stale data. The per-key generation token is what prevents that leak; see swr-pattern skill §9-7 (unibook #13475 / PR #13478). ⚠️ Do not addtransformer: restartable()on top of it: its token lives on theon<E>registration (not on your subscription slot) and is permanent once superseded, so one registration driving several keys — or a handler that returns early — permanently silences a still-live subscription without cancelling it.
Exception for Pagination "Load More"#
.last is appropriate for Load More (append):
- Since SWR cache key doesn't include offset, cached data is previous page data
- A permanent subscription would duplicate-append cache (previous page) + server (new page) data
// ✅ Keep .last for Load More
final result = await _useCases.getBooks(
PaginationParams(limit: 20, offset: currentOffset),
).last;
// Append after deduplication
final uniqueNew = newBooks.where((b) => !existingIds.contains(b.id)).toList();
emit(stateValue.copyWith(books: [...stateValue.books, ...uniqueNew]));
9. Cross-Module Mutation Cache Invalidation#
⚠️ DEPRECATED for cache invalidation (Epic #5474 Phase 8, #5483 / Epic #6021): the
DomainEventBuspattern below is no longer the recommended path for cache invalidation. Mutation → cache flows now go through the Cross-feature Patcher (swr-pattern skill §12) and repository-levelMutationRepositoryMixin(skill §15), with a zero-usage policy for cache-purposepublish/subscribe(skill §12-4-1).DomainEventBusremains valid only for analytics events, UI side effects (toast/snackbar), and non-cache cross-module notifications. The sections below are kept as historical context for understanding why the callback pattern was rejected.
The Problem#
SWR caches are scoped per module. A mutation in one module (e.g., store::PurchaseBookUsecase) often invalidates caches in
other modules (e.g., my_library::purchasedBooks, my_page::userWalletInfo). Without explicit invalidation, the consumer module sees stale data until cache TTL expires.
Example failure scenario:
- User purchases book in
store(10-minCachePolicies.standardTTL) - Within 10 min, user enters
my_library - SWR strategy: cache hit → no network fetch → stale data, purchased book not visible
Fix path: invalidate the consumer module's cache when the producer mutation succeeds.
❌ Anti-pattern: bootstrap callback registration (accumulates)#
// app/kobic/lib/bootstrap.dart - new mutation pollutes bootstrap
// (ServiceLocator: package/core/lib/src/app/service_locator.dart)
ServiceLocator.instance.registerSingleton<BookLikeChangedCallback>(...);
ServiceLocator.instance.registerSingleton<BookPurchasedCallback>(...);
ServiceLocator.instance.registerSingleton<BookReviewedCallback>(...);
// keeps growing as new mutations are added
| Issue | Why |
|---|---|
| SRP violation | Bootstrap takes responsibility for all domain cache invalidation |
| Low scalability | Every new mutation requires bootstrap edit |
| Low visibility | Reading my_library alone doesn't reveal which mutations it reacts to |
| Tight coupling |
Bootstrap must import all related modules (
my_library
,
my_page
,
store
, …)
|
(Historical) DomainEventBus pattern — superseded by Patcher / MutationRepositoryMixin for cache purposes#
Define domain events in package:core, register a single DomainEventBus in bootstrap, and let each module subscribe to events that affect its own caches.
// package:core - domain events + bus
abstract class DomainEvent {
const DomainEvent();
}
@immutable
final class BookPurchasedEvent extends DomainEvent {
const BookPurchasedEvent({required this.bookId});
final int bookId;
}
abstract class DomainEventBus {
void publish<T extends DomainEvent>(T event);
StreamSubscription<T> subscribe<T extends DomainEvent>(
void Function(T event) handler,
);
Future<void> dispose();
}
class InMemoryDomainEventBus implements DomainEventBus {
// broadcast StreamController + per-handler try/catch isolation
}
// app/kobic/lib/bootstrap.dart - register ONCE, no growth
// (console: app/kobic_console/lib/bootstrap.dart;
// ServiceLocator: package/core/lib/src/app/service_locator.dart)
ServiceLocator.instance.registerSingleton<DomainEventBus>(
InMemoryDomainEventBus(),
);
registerMyLibraryModule();
registerMyPageModule();
// feature/store - publisher
class PurchaseBookUsecase {
Future<void> call(...) async {
if (result.isRight()) {
try {
if (locator.isRegistered<DomainEventBus>()) {
locator.get<DomainEventBus>()
.publish(BookPurchasedEvent(bookId: bookId));
}
} on Object catch (error, stackTrace) {
Log.w('⚠️ event publish failed (isolated)', error: error);
}
}
}
}
// feature/my_library - subscriber owns its cache
void registerMyLibraryModule() {
final eventBus = ServiceLocator.instance.get<DomainEventBus>();
// ignore: avoid-unassigned-stream-subscriptions
eventBus.subscribe<BookPurchasedEvent>((event) {
unawaited(MyLibraryCacheInvalidator.fromDefault().invalidateAfterPurchase());
});
// ignore: avoid-unassigned-stream-subscriptions
eventBus.subscribe<BookLikeChangedEvent>((event) {
unawaited(MyLibraryCacheInvalidator.fromDefault().invalidateAfterToggleLike());
});
}
Why this works#
| Property | Callback pattern | EventBus pattern |
|---|---|---|
| Bootstrap responsibility | All mutation → module mappings | Register bus once + call module init |
| SRP | ❌ bootstrap owns all domains | ✅ each module owns its cache |
| Adding a mutation | Edit bootstrap + module | Module-only change |
| Producer/consumer coupling | Loose via typedef | Loose via event type |
| Visibility | Bootstrap reveals mappings | Module reveals its own subscriptions |
| Failure isolation | Caller's responsibility | Built-in per-handler try/catch |
Implementation tips#
@immutableon event classes — events should be value objects- Per-handler try/catch in the bus implementation — one subscriber's exception must not break others or the publisher
-
isRegistered<T>()check in publishers — register-failure should not cascade into mutation failure unawaited()inside subscribers — invalidation is fire-and-forget-
avoid-unassigned-stream-subscriptionslint suppression with comment — global subscriptions persist for app lifetime by design -
abstract, notsealedforDomainEvent— allows feature modules to define their own events without library lock-in -
Test pattern: subscribe → publish →
await Future<void>.delayed(Duration.zero)→ assert handler called
Real cases#
-
PR #5471 — Migrated
BookLikeChangedCallbackandBookPurchasedCallbacktoDomainEventBus -
PR #5469 (superseded by #5471) — Added
BookPurchasedCallbackfor cross-module invalidation - PR #5027 — Original
BookLikeChangedCallbackpattern that revealed the SRP issue
10. Checklist#
SWR Implementation Checklist#
- Use
async*+yieldpattern in Repository - UseCase returns
Stream<Either<Failure, T>> -
Use a container-owned
StreamSubscription+.listen(there is noemit.forEach) -
In a
close()override,await super.close()first (it flipsisClosedsynchronously), then cancel the detached subscription -
Re-subscribe path carries a per-key generation token —
cancel()alone leaks on overlap (skill §9-7, #13475) -
Do not attach
transformer: restartable()to a handler that opens a subscription — the generation token does the cancelling - Skip loading state when cache data exists
- Specify
onErrorcallback - Add
lastUpdatedor data source distinction field to State
Anti-pattern Avoidance Checklist#
-
NEVER use
.laston SWR Stream (infinite wait — stream doesn't close) -
NEVER use
await foron SWR Stream (handler hangs — same root cause) -
Use
.firstonly when (a) cache invalidation is guaranteed, OR (b) async transform is needed inside, OR (c) Pagination Load More -
Test with non-auto-closing
StreamController.broadcast()to verify no infinite wait —Stream.value(...)masks the bug
Cross-Module Mutation Checklist#
- Identify which consumer modules' caches are affected by the mutation
- Use
DomainEventBus(NOT bootstrap callback registration) - Define event in
package:corewith@immutable -
Publisher:
eventBus.publish(...)insidetry/catchwithisRegistered<DomainEventBus>()check -
Subscriber: register subscription inside the module's
registerXxxModule() -
Add cache invalidator method (e.g.,
invalidateAfterXxx()) to consumer module - Both L1 (memory) and L2 (disk) invalidation
- Test cache invalidation triggers and produces fresh data on next fetch
11. unawaited() + Error Silent-Failure Pitfall (Critical)#
Background: #7746 — an "infinite loading" bug found in
SwrStrategyImpl._revalidate(background revalidation). The root cause is not SWR-specific — it's a Dart language trait combined withunawaited()fire-and-forget — so it reproduces identically in non-SWR code. See [cc-dev:discovery-audit] for the codebase-wide audit → confirm → parallel-fix workflow used after discovery.
11-1. Why — Dart's Error does not extend Exception#
TypeError, StateError, NoSuchMethodError, FlutterError,
JsonUnsupportedObjectError, etc. are all Error, not Exception. In practice, almost every catch block conventionally uses
on Exception catch only.
This alone isn't a problem for a normal await-chained call — an uncaught Error
propagates up and eventually surfaces as a crash or a log. The problem is when this catch chain sits behind
unawaited(someFunction()) (fire-and-forget). There, an Error missed by on Exception
becomes an exception no code path is set up to observe. Whether it also surfaces as a crash/log elsewhere depends on the execution zone (a plain Dart isolate prints "Unhandled exception" and aborts; a Flutter app's guarded zone may route it to
PlatformDispatcher.onError instead, or swallow it depending on what that handler does) — but regardless of that,
the specific consumer awaiting this result never gets a terminal emission: BLoC/Cubit state, a
ValueNotifier/useState-gated button or spinner, a StreamController
all hang forever waiting for a response that never arrives. Infinite loading spinners, permanently-disabled buttons, and dialogs that never close are the typical symptoms.
11-2. Reconstructed example (SwrStrategyImpl._revalidate pattern)#
// package/cache/lib/src/data/swr_strategy_impl.dart (reconstructed — illustrates the pattern, not the literal diff)
void onListen() {
// 1. emit cached data immediately
// 2. background revalidate is fire-and-forget — this is the trap
unawaited(_revalidate(query, controller)); // ❌ any Error thrown inside disappears completely
}
Future<void> _revalidate(
CacheQuery query,
StreamController<Either<Failure, T>> controller,
) async {
try {
final fresh = await _fetcher(query);
await saveToCache(query.cacheKey, fresh); // may throw an Error internally (e.g. jsonEncode)
if (!controller.isClosed) controller.add(right(fresh));
} on Exception catch (error, stackTrace) { // ❌ Error (TypeError/JsonUnsupportedObjectError/...) is NOT caught here
Log.e('background revalidate failed', error: error, stackTrace: stackTrace);
if (!controller.isClosed) controller.add(left(NetworkFailure(error.toString())));
}
}
If something inside saveToCache or the response mapping throws an Error (e.g.
JsonUnsupportedObjectError from a non-serializable object, or TypeError from an unsafe
as T cast), it passes straight through on Exception catch and exits _revalidate. Neither
controller.add branch runs, and since the call is unawaited(), nobody awaits the Future to notice the failure. Result: any BLoC subscribed to that
cacheKey never receives a terminal emission and the screen stays stuck in loading state.
11-3. Detection — grep-able once known#
# candidates — every production file with a fire-and-forget unawaited( call
grep -rl "unawaited(" --include="*.dart" feature/ package/ app/ | grep -v _test.dart
# then manually trace the full call chain of each unawaited(fn()) to check
# whether EVERY catch clause in that chain is `on Exception` only (misses Error)
Full automation is hard because the offending catch can live in a function several calls below
the one passed to unawaited() — the full call chain must be traced. This is why a separate triage (cast a wide net) → verify (trace the chain, confirm) split matters; see [cc-dev:discovery-audit].
11-4. Real case#
An audit of all 274 production files containing unawaited( in kobic confirmed 11 independent reproducible instances (including a high-severity full EPUB-reader hang). Two "high" severity candidates were rejected as dead/@Deprecated
code, and one was rejected because its consumer was a BehaviorSubject.seeded() — it doesn't hang, it just serves stale data until the next update.
11-5. Fix#
// ✅ on Object catch covers both Exception and Error
} on Object catch (error, stackTrace) {
Log.e('background revalidate failed', error: error, stackTrace: stackTrace);
if (!controller.isClosed) controller.add(left(NetworkFailure(error.toString())));
}
Any function reached via unawaited() should widen its catch to on Object catch
(or on Exception catch + a parallel on Error catch) — this is the one place where the usual
on Exception-only convention is not enough.
11-6. Checklist#
-
Before adding a new
unawaited(fn())call, verify the full call chain offn()also catchesError -
The top-level catch of any function reached via
unawaited()ison Object(oron Exception+on Error) - The consumer (BLoC/Cubit/ValueNotifier/StreamController) is guaranteed a terminal emission (success or failure) even on this path
- Same item is mirrored in the code review checklist: [cc-quality:code-review-checklist] §2 State Management
Related Documents#
- swr-pattern skill - Current canonical guide (§9 permanent subscription, §12 Cross-feature Patcher, §15 MutationRepositoryMixin, §16 error-delivery contract, §17 cacheKey design principles) — this rule file predates it; on conflict, the skill wins
- 프로젝트 루트의
CLAUDE.md(AGENTS.md) - Complete project guide -
<project>/package/core/lib/src/domain/usecase/auth_required_usecase.dart- Existing UseCase Pattern - unibook #13475 / PR #13478 - zombie subscription from overlapping same-key re-subscription; per-key generation-token fix (skill §9-7)
-
#7746 -
SwrStrategyImpl._revalidateinfinite-loading fix caused byunawaited()+ missedError - #8334 - Cache-layer safety hardening (watch addError→Left, saveToCache Left direct emit, cold-start freshness, prefix semantics)
- [cc-dev:discovery-audit] - Workflow used to audit/confirm/parallel-fix this bug pattern across the codebase