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 + restartable()#
โ ๏ธ
emit.forEachdoes not exist inbloc_signals. Own theStreamSubscriptionand cancel it inclose().
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,
transformer: restartable(), // superseded ์คํ์ emission ํ๊ธฐ
);
}
final GetItemsStreamUseCase _getItemsStream;
StreamSubscription<Either<Failure, List<Item>>>? _itemsSub;
Future<void> _onLoad(_Load event, void Function(ItemState) emit) async {
if (stateValue.items.isEmpty) {
emit(stateValue.copyWith(status: LoadingStatus.loading));
}
await _itemsSub?.cancel();
_itemsSub = _getItemsStream(
GetItemsParams(categoryId: event.categoryId),
).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(),
),
));
});
}
@override
Future<void> close() async {
if (isClosed) return;
await _itemsSub?.cancel(); // โ ๏ธ no automatic cleanup
await super.close();
}
}
How restartable() Works
1. add(_Load(categoryId: 1)) โ ์คํธ๋ฆผA ๊ตฌ๋
(์บ์ โ ์๋ฒ ์์ฐจ yield)
2. add(_Load(categoryId: 2)) โ ํธ๋ค๋ฌ๊ฐ _itemsSub.cancel() ๋ก ์คํธ๋ฆผA ํด์
โ ์คํธ๋ฆผB ๊ตฌ๋
์์
โ restartable() ์ ํธ๋ค๋ฌ1 ์ ๋ฆ์ emission ์ ํ๊ธฐ
โ ๏ธ bloc_signals' restartable() only discards emissions from the superseded execution โ it does
not cancel the stream subscription. Holding the StreamSubscription on the container and cancelling it
in a close() override is required; there is no automatic cleanup.
See swr-pattern.
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