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