UseCase Patterns
UseCase is a core component of Clean Architecture that encapsulates business logic.
Reference location:
.claude/references/patterns/usecase-patterns.md
UseCase is a core component of Clean Architecture that encapsulates business logic.
Standard Pattern#
| Pattern | Description |
|---|---|
| Optional Constructor Injection | ✅ Standard pattern - Easy mock injection, no getIt dependency |
Future UseCase (Single Request)#
UseCase Definition#
// domain/usecase/get_user_usecase.dart
class GetUserUseCase {
/// [GetUserUseCase]를 생성합니다.
const GetUserUseCase([IUserRepository? repository])
: _repo = repository ?? const UserRepository();
final IUserRepository _repo;
Future<Either<Failure, User>> call(GetUserParams params) async {
return _repo.getUser(params.id);
}
}
class GetUserParams {
const GetUserParams({required this.id});
final int id;
}
Usage in BLoC (DI Decoupling)#
class UserBloc extends BlocSignal<UserEvent, UserState> {
UserBloc({
GetUserUseCase? getUserUseCase,
}) : _getUserUseCase = getUserUseCase ?? const GetUserUseCase(),
super(initialState: const UserInitial()) {
on<UserLoad>(_onLoad);
}
final GetUserUseCase _getUserUseCase;
Future<void> _onLoad(UserLoad event, void Function(UserState) emit) async {
emit(const UserLoading());
final result = await _getUserUseCase(
GetUserParams(id: event.userId),
);
if (isClosed) return; // await 후 체크 필수!
result.fold(
(failure) => emit(UserError(failure: failure)),
(user) => emit(UserLoaded(user: user)),
);
}
}
Tests#
class MockGetUserUseCase extends Mock implements GetUserUseCase {}
blocSignalTest<UserBloc, UserState>(
'emits [loading, loaded] when load succeeds',
setUp: () {
when(() => mockGetUser(any()))
.thenAnswer((_) async => right(testUser));
},
build: () => UserBloc(getUserUseCase: mockGetUser),
act: (bloc) => bloc.add(const UserLoad(userId: 1)),
expect: () => [
const UserLoading(),
UserLoaded(user: testUser),
],
);
Stream UseCase (SWR/Cache-First)#
A Stream-based UseCase that sequentially emits cached data and server data.
StreamUseCase Interface#
/// Stream 기반 UseCase (SWR 패턴용)
abstract class StreamUseCase<T, Params, Repo> {
Repo get repo;
Stream<Either<Failure, T>> call(Params param);
}
StreamUseCase Implementation#
class GetItemsStreamUseCase
implements StreamUseCase<List<Item>, GetItemsParams, IItemRepository> {
const GetItemsStreamUseCase([IItemRepository? repository])
: _repository = repository ?? const ItemRepository();
final IItemRepository _repository;
@override
IItemRepository get repo => _repository;
@override
Stream<Either<Failure, List<Item>>> call(GetItemsParams params) {
return repo.getItemsWithCache(categoryId: params.categoryId);
}
}
Usage in BlocSignal: container-owned subscription + generation token#
⚠️
emit.forEachdoes not exist inbloc_signals. Own theStreamSubscription, version every re-subscription with a generation token, and cancel it inclose().The full rules live in swr-pattern §9-2 · §9-7; unibook's canonical implementation is
SwrBlocSignalMixininpackage/core. TheisClosedcheck before registration and thesuper.close()-first ordering below are extra defence the canonical mixin does not have yet.
import 'dart:async'; // unawaited
import 'package:bloc_signals/bloc_signals.dart';
class ItemBloc extends BlocSignal<ItemEvent, ItemState> {
ItemBloc({
GetItemsStreamUseCase? getItemsStream,
}) : _getItemsStream = getItemsStream ?? const GetItemsStreamUseCase(),
super(initialState: const ItemState()) {
on<_Load>(_onLoad); // no transformer — the generation token below owns the restart
}
final GetItemsStreamUseCase _getItemsStream;
StreamSubscription<Either<Failure, List<Item>>>? _itemsSub;
int _generation = 0; // one counter per subscription slot
Future<void> _onLoad(_Load event, void Function(ItemState) emit) async {
final generation = ++_generation; // claim the slot before the first await
final previous = _itemsSub;
_itemsSub = null; // detach BEFORE awaiting — this call must not null out
await previous?.cancel(); // a newer registration when it resumes
if (isClosed) return; // close() does NOT cancel an in-flight handler
if (generation != _generation) return; // a newer run owns the slot — do NOT subscribe
if (stateValue.items.isEmpty) {
emit(stateValue.copyWith(status: LoadingStatus.loading));
}
final stream = _getItemsStream(GetItemsParams(categoryId: event.categoryId));
// `late final` is safe here — `onDone` cannot fire before `listen` returns.
late final StreamSubscription<Either<Failure, List<Item>>> subscription;
subscription = stream.listen(
(result) {
if (isClosed) return;
emit(result.fold(
(failure) => stateValue.copyWith(
status: LoadingStatus.error,
failure: failure,
),
(items) => stateValue.copyWith(
status: LoadingStatus.loaded,
items: items,
lastUpdated: DateTime.now(),
),
));
},
onError: (Object error, StackTrace stackTrace) {
// Defensive: the cache layer is contracted to surface failures as a `Left`.
Log.e('SWR stream error', error: error, stackTrace: stackTrace);
if (isClosed) return;
emit(stateValue.copyWith(
status: LoadingStatus.error,
failure: Failure.unexpected(error.toString()),
));
},
onDone: () {
// Deregister only while the slot still holds THIS subscription.
if (identical(_itemsSub, subscription)) _itemsSub = null;
},
cancelOnError: false, // keep watching after a recoverable error
);
// `emit` and `listen` above can synchronously re-dispatch the same event — or
// synchronously close the container. (The canonical mixin checks only the
// generation here; `isClosed` is extra defence for a hand-rolled slot.)
if (isClosed || generation != _generation) {
await subscription.cancel(); // close the one just opened, then step aside
return;
}
final stale = _itemsSub;
if (stale != null && !identical(stale, subscription)) {
unawaited(stale.cancel()); // never overwrite a live subscription
}
_itemsSub = subscription;
}
/// An explicit cancel (filter reset, leaving the screen) must bump the
/// generation too — otherwise an in-flight `_onLoad` subscribes after it.
Future<void> cancelLoad() {
_generation++;
final subscription = _itemsSub;
_itemsSub = null;
return subscription?.cancel() ?? Future<void>.value();
}
@override
Future<void> close() async {
if (isClosed) return;
// Detach first, then call `super.close()` BEFORE cancelling: `isClosed` is set
// synchronously inside `super.close()` (before any `await`), so a handler that
// resumes during the cancel steps aside at the guards above.
final subscription = _itemsSub;
_itemsSub = null;
await super.close();
await subscription?.cancel(); // ⚠️ no automatic cleanup
}
}
세대 토큰이 하는 일
1. add(_Load(categoryId: 1)) → 스트림A 구독 (캐시 → 서버 순차 yield)
2. add(_Load(categoryId: 2)) → 슬롯에서 먼저 뗀 뒤 스트림A 해제 → 스트림B 구독
3. 1·2 가 await 사이에 겹침 → 낡은 세대는 구독을 열지 않고 물러난다
→ 살아 있는 구독은 키당 언제나 하나뿐
// ❌ 옛 형태 — 두 호출이 겹치면 구독이 샌다 (unibook #13475)
await _itemsSub?.cancel(); // 슬롯이 await 내내 비어 있고
_itemsSub = _getItemsStream(...).listen(...); // 겹친 두 호출이 각자 구독을 연 뒤
// 나중에 대입하는 쪽이 앞의 것을
// 취소 없이 덮어쓴다 → 좀비 구독
⚠️ Do not put restartable() on this handler. bloc_signals'
restartable() only gates the
superseded run's emissions — it cancels neither the run nor any subscription it opened. Its token
(var executionToken in event_transformers.dart) sits outside the returned closure, so it is scoped to
the on<E> registration rather than to a subscription key, and it is permanent once superseded. With
the generation token the superseded run steps aside before any emit
and the subscription it opened
is really cancelled, so restartable() has nothing left to discard (net effect 0) while its risk remains:
one registration driving two slots, or a handler that returns early, would silence a still-live
subscription without cancelling it. It stays legitimate on handlers that open no subscription and only
await (search-as-you-type, polling).
⚠️ Holding the StreamSubscription on the container and cancelling it in a close()
override is
required — there is no automatic cleanup. But that alone is not sufficient: without the
generation token two overlapping runs each open a subscription and the later assignment overwrites the
earlier one uncancelled, leaving a subscription nothing can reach (unibook #13475).
See swr-pattern §9-4 · §9-7.
Repository SWR Stream Implementation#
@override
Stream<Either<Failure, List<Item>>> getItemsWithCache({
required int categoryId,
}) async* {
// 1. 캐시 데이터 즉시 yield
final cached = await _itemDao.getByCategoryId(categoryId);
if (cached != null && cached.isNotEmpty) {
yield right(cached.map((e) => e.toDomain()).toList());
}
// 2. 서버 데이터 fetch → yield
try {
final remote = await _client.item.getItems(categoryId: categoryId);
await _itemDao.upsertAll(remote.map((e) => e.toLocal()).toList());
yield right(remote);
} on Exception catch (error, stackTrace) {
if (cached == null || cached.isEmpty) {
yield left(ItemFailure(message: error.toString()));
}
// Ignore error if cache existed (cached data already displayed)
Log.e('서버 갱신 실패', error: error, stackTrace: stackTrace);
}
}
Stream Test#
class MockGetItemsStreamUseCase extends Mock implements GetItemsStreamUseCase {}
blocSignalTest<ItemBloc, ItemState>(
'emits cached then fresh data via SWR stream',
setUp: () {
when(() => mockGetItemsStream(any())).thenAnswer(
(_) => Stream.fromIterable([
right(cachedItems),
right(freshItems),
]),
);
},
build: () => ItemBloc(getItemsStream: mockGetItemsStream),
act: (bloc) => bloc.add(const ItemEvent.load(categoryId: 1)),
expect: () => [
ItemState(status: LoadingStatus.loading),
ItemState(status: LoadingStatus.loaded, items: cachedItems),
ItemState(status: LoadingStatus.loaded, items: freshItems),
],
);
UseCase Bundle (8 or more)#
When constructor parameters become numerous, group them into a Bundle class.
class DashboardUseCases {
const DashboardUseCases({
GetStatsUseCase? getStats,
GetChartDataUseCase? getChartData,
GetRecentItemsUseCase? getRecentItems,
}) : getStats = getStats ?? const GetStatsUseCase(),
getChartData = getChartData ?? const GetChartDataUseCase(),
getRecentItems = getRecentItems ?? const GetRecentItemsUseCase();
final GetStatsUseCase getStats;
final GetChartDataUseCase getChartData;
final GetRecentItemsUseCase getRecentItems;
}
class DashboardBloc extends BlocSignal<DashboardEvent, DashboardState> {
DashboardBloc({DashboardUseCases? useCases})
: _useCases = useCases ?? const DashboardUseCases(),
super(initialState: const DashboardState()) {
on<_Load>(_onLoad);
}
final DashboardUseCases _useCases;
}
Selection Guide#
데이터 흐름?
├── 단건 요청 → Future UseCase
└── 캐시+서버 순차 → Stream UseCase + 컨테이너 소유 구독 + restartable()
UseCase injection → Optional Constructor Injection (standard)
UseCase 개수?
├── 1~7개 → 개별 생성자 파라미터
└── 8개+ → UseCase Bundle 패턴
Referencing Agents#
/cc-flutter:feature:domain- Domain Layer Generation/feature:bloc- BLoC state management/cc-flutter:feature:presentation- Presentation Layer generation