LogoSkills

BLoC-UseCase DI Pattern: Optional Constructor Injection

Standard DI pattern for improving testability when using UseCase in BLoC.

Standard DI pattern for improving testability when using UseCase in BLoC.

Core Principles#

  • Optional Constructor Injection: Inject UseCase as optional named parameter
  • Default Fallback: Provide production default with ?? const SomeUseCase()
  • Mock injection in tests: Direct mock passing via constructor

Basic Pattern (1-7 UseCase)#

@injectable
class MyBloc extends Bloc<MyEvent, MyState> {
  MyBloc({
    GetDataUseCase? getDataUseCase,
    UpdateDataUseCase? updateDataUseCase,
  }) : _getDataUseCase = getDataUseCase ?? const GetDataUseCase(),
       _updateDataUseCase = updateDataUseCase ?? const UpdateDataUseCase(),
       super(const MyState()) {
    on<_LoadData>(_onLoadData);
    on<_UpdateData>(_onUpdateData);
  }

  final GetDataUseCase _getDataUseCase;
  final UpdateDataUseCase _updateDataUseCase;

  Future<void> _onLoadData(
    _LoadData event,
    Emitter<MyState> emit,
  ) async {
    emit(state.copyWith(status: const MyStatus.loading()));

    final result = await _getDataUseCase(GetDataParams(id: event.id));
    if (isClosed) return;

    emit(result.fold(
      (failure) => state.copyWith(status: MyStatus.error(failure.toString())),
      (data) => state.copyWith(status: const MyStatus.loaded(), data: data),
    ));
  }
}

Rules#

ItemRule
Parameter typeGetDataUseCase? (nullable)
Default?? const GetDataUseCase() (const constructor)
Fieldfinal GetDataUseCase _getDataUseCase (private, non-nullable)
Callawait _getDataUseCase(params) (.call() omitted)
After asyncif (isClosed) return; required

Bundle Pattern (8+ UseCase)#

BLoCs with 8 or more UseCases are grouped using a Bundle Class.

/// MyBloc에서 사용하는 UseCase 번들
class MyBlocUseCases {
  const MyBlocUseCases({
    this.getData = const GetDataUseCase(),
    this.updateData = const UpdateDataUseCase(),
    this.deleteData = const DeleteDataUseCase(),
    this.searchData = const SearchDataUseCase(),
    this.filterData = const FilterDataUseCase(),
    this.sortData = const SortDataUseCase(),
    this.exportData = const ExportDataUseCase(),
    this.importData = const ImportDataUseCase(),
    this.validateData = const ValidateDataUseCase(),
  });

  final GetDataUseCase getData;
  final UpdateDataUseCase updateData;
  final DeleteDataUseCase deleteData;
  final SearchDataUseCase searchData;
  final FilterDataUseCase filterData;
  final SortDataUseCase sortData;
  final ExportDataUseCase exportData;
  final ImportDataUseCase importData;
  final ValidateDataUseCase validateData;
}

@injectable
class MyBloc extends Bloc<MyEvent, MyState> {
  MyBloc({MyBlocUseCases? useCases})
    : _useCases = useCases ?? const MyBlocUseCases(),
      super(const MyState()) {
    on<_LoadData>(_onLoadData);
  }

  final MyBlocUseCases _useCases;

  Future<void> _onLoadData(
    _LoadData event,
    Emitter<MyState> emit,
  ) async {
    final result = await _useCases.getData(GetDataParams(id: event.id));
    if (isClosed) return;
    // ...
  }
}

Bundle File Location#

feature/{type}/{feature_name}/lib/src/presentation/bloc/{bloc_name}/
├── {bloc_name}_bloc.dart
├── {bloc_name}_event.dart
├── {bloc_name}_state.dart
└── {bloc_name}_use_cases.dart   ← Bundle 클래스

Test Patterns#

Basic Pattern Test#

late MockGetDataUseCase mockGetDataUseCase;
late MockUpdateDataUseCase mockUpdateDataUseCase;
late MyBloc bloc;

setUp(() {
  mockGetDataUseCase = MockGetDataUseCase();
  mockUpdateDataUseCase = MockUpdateDataUseCase();
  bloc = MyBloc(
    getDataUseCase: mockGetDataUseCase,
    updateDataUseCase: mockUpdateDataUseCase,
  );
});

Bundle Pattern Test#

late MyBloc bloc;

setUp(() {
  bloc = MyBloc(
    useCases: MyBlocUseCases(
      getData: MockGetDataUseCase(),
      updateData: MockUpdateDataUseCase(),
      // Mock only what's needed, rest use defaults
    ),
  );
});

Using blocTest#

blocTest<MyBloc, MyState>(
  'Transitions to loaded state on successful data load',
  build: () {
    when(() => mockGetDataUseCase(any()))
        .thenAnswer((_) async => Right(testData));
    return MyBloc(getDataUseCase: mockGetDataUseCase);
  },
  act: (bloc) => bloc.add(const MyEvent.loadData(id: 1)),
  expect: () => [
    isA<MyState>().having((s) => s.status, 'status', isA<MyStatusLoading>()),
    isA<MyState>().having((s) => s.data, 'data', testData),
  ],
);

Before to After Migration#

Before (Inline UseCase Call)#

// ❌ WRONG: Inline const UseCase().call() usage
class MyBloc extends Bloc<MyEvent, MyState> {
  MyBloc() : super(const MyState()) {
    on<_LoadData>(_onLoadData);
  }

  Future<void> _onLoadData(_LoadData event, Emitter<MyState> emit) async {
    final result = await const GetDataUseCase().call(
      GetDataParams(id: event.id),
    );
    if (isClosed) return;
    // ...
  }
}

After (Optional Constructor Injection)#

// ✅ CORRECT: Optional Constructor Injection
class MyBloc extends Bloc<MyEvent, MyState> {
  MyBloc({
    GetDataUseCase? getDataUseCase,
  }) : _getDataUseCase = getDataUseCase ?? const GetDataUseCase(),
       super(const MyState()) {
    on<_LoadData>(_onLoadData);
  }

  final GetDataUseCase _getDataUseCase;

  Future<void> _onLoadData(_LoadData event, Emitter<MyState> emit) async {
    final result = await _getDataUseCase(GetDataParams(id: event.id));
    if (isClosed) return;
    // ...
  }
}

Migration Checklist#

  1. Find all const SomeUseCase().call(params) patterns
  2. Add optional named parameter to constructor
  3. Set ?? const SomeUseCase() default in initializer list
  4. Declare final field
  5. Change call to _useCase(params) form in handlers
  6. Remove explicit .call() (Dart callable class)
  7. Keep @injectable annotation (GetIt compatible)
  8. Verify existing tests pass

@injectable Compatibility#

Optional Constructor Injection is fully compatible with @injectable. GetIt ignores optional parameters with defaults and only injects registered dependencies.

// When registering with GetIt: optional parameters use defaults
@injectable
class MyBloc extends Bloc<MyEvent, MyState> {
  MyBloc({
    GetDataUseCase? getDataUseCase,  // GetIt이 주입하지 않음 → default값 사용
  }) : _getDataUseCase = getDataUseCase ?? const GetDataUseCase(),
       super(const MyState());
}

// Production: getIt<MyBloc>() -> uses default UseCase
// Test: MyBloc(getDataUseCase: mockUseCase) -> inject mock

Application Criteria#

UseCase CountPatternNote
1-7Basic pattern (individual parameters)Most BLoCs
8+Bundle pattern (UseCases class)Complex BLoCs