LogoSkills

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#

PatternDescription
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 Bloc<UserEvent, UserState> {
  UserBloc({
    GetUserUseCase? getUserUseCase,
  }) : _getUserUseCase = getUserUseCase ?? const GetUserUseCase(),
       super(const UserInitial()) {
    on<UserLoad>(_onLoad);
  }

  final GetUserUseCase _getUserUseCase;

  Future<void> _onLoad(UserLoad event, Emitter<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 {}

blocTest<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 BLoC: emit.forEach + restartable()#

import 'package:bloc_concurrency/bloc_concurrency.dart';

class ItemBloc extends Bloc<ItemEvent, ItemState> {
  ItemBloc({
    GetItemsStreamUseCase? getItemsStream,
  }) : _getItemsStream = getItemsStream ?? const GetItemsStreamUseCase(),
       super(const ItemState()) {
    on<_Load>(
      _onLoad,
      transformer: restartable(),  // 파라미터 변경 시 이전 스트림 자동 취소
    );
  }

  final GetItemsStreamUseCase _getItemsStream;

  Future<void> _onLoad(_Load event, Emitter<ItemState> emit) async {
    if (state.items.isEmpty) {
      emit(state.copyWith(status: LoadingStatus.loading));
    }

    await emit.forEach<Either<Failure, List<Item>>>(
      _getItemsStream(GetItemsParams(categoryId: event.categoryId)),
      onData: (result) => result.fold(
        (failure) => state.copyWith(
          status: LoadingStatus.error,
          failure: failure,
        ),
        (items) => state.copyWith(
          status: LoadingStatus.loaded,
          items: items,
          lastUpdated: DateTime.now(),
        ),
      ),
    );
  }
}

How restartable() Works

1. add(_Load(categoryId: 1))  → 스트림A 구독 (캐시 → 서버 순차 yield)
2. add(_Load(categoryId: 2))  → restartable()가 핸들러1 취소 → 스트림A 해제
                               → 핸들러2 시작 → 스트림B 구독

emit.forEach internally calls stream.listen(), and when restartable() cancels the handler, the subscription is automatically cleaned up. No need to override close().

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 {}

blocTest<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 Bloc<DashboardEvent, DashboardState> {
  DashboardBloc({DashboardUseCases? useCases})
      : _useCases = useCases ?? const DashboardUseCases(),
        super(const DashboardState()) {
    on<_Load>(_onLoad);
  }

  final DashboardUseCases _useCases;
}

Selection Guide#

데이터 흐름?
├── 단건 요청 → Future UseCase
└── 캐시+서버 순차 → Stream UseCase + emit.forEach + restartable()

UseCase injection → Optional Constructor Injection (standard)

UseCase 개수?
├── 1~7개 → 개별 생성자 파라미터
└── 8개+ → UseCase Bundle 패턴

Referencing Agents#

  • /feature:domain - Domain Layer Generation
  • /feature:bloc - BLoC state management
  • /feature:presentation - Presentation Layer generation