문서 목차
이 페이지에서목차

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 + generation token#

⚠️ emit.forEach does not exist in bloc_signals. Own the StreamSubscription, version every re-subscription with a generation token, and cancel it in close().

The full rules live in swr-pattern §9-2 · §9-7; unibook's canonical implementation is SwrBlocSignalMixin in package/core. The isClosed check before registration and the super.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

검색어를 입력하면 문서를 찾습니다.

↑ ↓ 이동 · Enter 열기Esc 닫기