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 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.forEach does not exist in bloc_signals. Own the StreamSubscription and cancel it in close().

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