flutter
/

Use Case Layer: Domain-Driven Business Logic in Flutter Bloc

Last Sync: Today

On this page

14
0%
5 min read
Remaining
5 minleft

Click any section to jump — progress syncs automatically

flutter

Use Case Layer: Domain-Driven Business Logic in Flutter Bloc

In large Flutter applications, business logic can become complex and spread across multiple blocs. The use case layer (also called the interactor or domain layer) provides a dedicated place for business rules, orchestrating repositories and other dependencies. This guide explains how to implement use cases with Bloc to create scalable, testable applications.

What is a Use Case?

A use case represents a single, specific action that your application can perform – for example, "login with email and password" or "fetch user profile". It contains the business logic for that action, orchestrating one or more repositories. Use cases are typically stateless and receive input parameters, then return a result (or throw an error).

Why Use Cases with Bloc?

  • Separation of concerns – Blocs focus on state management and event handling; use cases contain domain logic.
  • Reusability – The same use case can be called from different blocs (e.g., FetchUser from both ProfileBloc and SettingsBloc).
  • Testability – Use cases are pure Dart classes that can be unit‑tested without any Flutter dependencies.
  • Single responsibility – Each use case does one thing and does it well.
  • Clarity – Business logic is explicit and easy to understand, even for non‑technical stakeholders.

Core Components

ComponentResponsibility
Use Case InterfaceDefines the contract for a specific action (optional)
Use Case ImplementationContains the business logic, uses repositories
BlocCalls use cases, handles results, emits states
RepositoryProvides data, used by use cases

Step-by-Step Implementation

Create a class that represents the use case. It should depend on repositories and expose an execute (or call) method. Use Either or a custom result type for error handling, or throw exceptions.

DARTRead-only
1
// lib/features/auth/domain/usecases/login_usecase.dart
import 'package:dartz/dartz.dart';
import 'package:myapp/core/error/failures.dart';
import 'package:myapp/features/auth/domain/entities/user.dart';
import 'package:myapp/features/auth/domain/repositories/auth_repository.dart';

class LoginUseCase {
  final AuthRepository repository;

  LoginUseCase(this.repository);

  Future<Either<Failure, User>> execute(String email, String password) async {
    try {
      final user = await repository.login(email, password);
      return Right(user);
    } catch (e) {
      return Left(ServerFailure(e.toString()));
    }
  }
}

Inject the use case into the bloc and call it in the event handler. The bloc then emits states based on the result.

DARTRead-only
1
// lib/features/auth/bloc/auth_bloc.dart
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final LoginUseCase loginUseCase;

  AuthBloc(this.loginUseCase) : super(AuthInitial()) {
    on<LoginRequested>(_onLoginRequested);
  }

  Future<void> _onLoginRequested(
    LoginRequested event,
    Emitter<AuthState> emit,
  ) async {
    emit(AuthLoading());
    final result = await loginUseCase.execute(event.email, event.password);
    result.fold(
      (failure) => emit(AuthFailure(failure.message)),
      (user) => emit(AuthSuccess(user)),
    );
  }
}

To have consistent error handling, define a hierarchy of failures (or exceptions).

DARTRead-only
1
// lib/core/error/failures.dart
abstract class Failure {
  final String message;
  Failure(this.message);
}

class ServerFailure extends Failure {
  ServerFailure(String message) : super(message);
}

class CacheFailure extends Failure {
  CacheFailure(String message) : super(message);
}

class NetworkFailure extends Failure {
  NetworkFailure(String message) : super(message);
}

class ValidationFailure extends Failure {
  ValidationFailure(String message) : super(message);
}

Use Cases with Parameters

For use cases that require multiple parameters, define a simple parameter object to keep the API clean and allow easier extension.

DARTRead-only
1
class GetUserProfileUseCase {
  final UserRepository repository;

  GetUserProfileUseCase(this.repository);

  Future<Either<Failure, User>> execute(Params params) async {
    return repository.getUser(params.userId);
  }
}

class Params {
  final String userId;
  Params(this.userId);
}

Use Cases that Combine Multiple Repositories

One of the main benefits of use cases is orchestrating multiple repositories. For example, a RegisterUserUseCase might call both AuthRepository and AnalyticsRepository.

DARTRead-only
1
class RegisterUserUseCase {
  final AuthRepository authRepository;
  final AnalyticsRepository analyticsRepository;

  RegisterUserUseCase(this.authRepository, this.analyticsRepository);

  Future<Either<Failure, User>> execute(RegisterParams params) async {
    try {
      final user = await authRepository.register(params.email, params.password);
      await analyticsRepository.trackEvent('user_registered', {'email': params.email});
      return Right(user);
    } catch (e) {
      return Left(ServerFailure(e.toString()));
    }
  }
}

Dependency Injection for Use Cases

Use a service locator like get_it to register use cases and inject repositories. This makes them available to your blocs.

DARTRead-only
1
// lib/di/injection.dart
import 'package:get_it/get_it.dart';

final getIt = GetIt.instance;

void setup() {
  // Repositories
  getIt.registerLazySingleton<AuthRepository>(() => AuthRepositoryImpl(...));

  // Use cases
  getIt.registerLazySingleton(() => LoginUseCase(getIt<AuthRepository>()));
  getIt.registerLazySingleton(() => RegisterUserUseCase(
    getIt<AuthRepository>(),
    getIt<AnalyticsRepository>(),
  ));

  // Blocs
  getIt.registerFactory(() => AuthBloc(getIt<LoginUseCase>(), getIt<RegisterUserUseCase>()));
}

Testing Use Cases

Use cases are easy to test because they have no Flutter dependencies. You can mock repositories and verify the business logic.

DARTRead-only
1
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';

class MockAuthRepository extends Mock implements AuthRepository {}

void main() {
  late MockAuthRepository mockRepository;
  late LoginUseCase useCase;

  setUp(() {
    mockRepository = MockAuthRepository();
    useCase = LoginUseCase(mockRepository);
  });

  test('returns User when login succeeds', () async {
    final user = User(id: '1', name: 'Test', email: 'test@example.com');
    when(() => mockRepository.login('test@example.com', 'password'))
        .thenAnswer((_) async => user);

    final result = await useCase.execute('test@example.com', 'password');

    expect(result.isRight(), true);
    result.fold(
      (failure) => fail('Should not be failure'),
      (userResult) => expect(userResult, user),
    );
  });

  test('returns Failure when login fails', () async {
    when(() => mockRepository.login('wrong', 'wrong'))
        .thenThrow(Exception('Invalid credentials'));

    final result = await useCase.execute('wrong', 'wrong');

    expect(result.isLeft(), true);
    result.fold(
      (failure) => expect(failure.message, 'Exception: Invalid credentials'),
      (user) => fail('Should not be user'),
    );
  });
}

Use Case Variations

Some use cases return a stream of data (e.g., real-time updates). The use case can expose a stream, and the bloc can subscribe to it.

DARTRead-only
1
class GetMessagesUseCase {
  final MessageRepository repository;

  GetMessagesUseCase(this.repository);

  Stream<List<Message>> execute() {
    return repository.getMessages();
  }
}

// In bloc:
on<SubscribeToMessages>((event, emit) async {
  await emit.forEach(
    getMessagesUseCase.execute(),
    onData: (messages) => MessagesLoaded(messages),
  );
});

You can add validation inside the use case before calling repositories, returning a ValidationFailure if input is invalid.

DARTRead-only
1
class RegisterUseCase {
  final AuthRepository repository;

  RegisterUseCase(this.repository);

  Future<Either<Failure, User>> execute(String email, String password) async {
    if (!isValidEmail(email)) {
      return Left(ValidationFailure('Invalid email format'));
    }
    if (password.length < 6) {
      return Left(ValidationFailure('Password too short'));
    }
    return repository.register(email, password);
  }
}

When to Use Use Cases

  • Complex business logic – When a bloc would become large and contain many conditional branches.
  • Shared logic – When the same business rule is needed in multiple blocs.
  • Testing – When you want to test business logic without UI dependencies.
  • Clean Architecture – When you want to strictly separate domain, data, and presentation layers.

When to Skip Use Cases

  • Simple CRUD apps – If a bloc only calls a single repository method, the use case adds boilerplate.
  • Prototypes – For speed, you can put logic directly in the bloc and refactor later.
  • Very small teams/projects – Sometimes simplicity is better than perfect architecture.

Best Practices

  • Keep use cases stateless – No mutable state inside a use case (it can be reused across calls).
  • Use meaningful names – LoginUseCase, FetchUserProfileUseCase – the name should describe the action.
  • Return a result type – Use Either or a custom Result to make error handling explicit.
  • Inject dependencies – Never instantiate repositories inside a use case.
  • Test use cases thoroughly – Write unit tests for both success and error paths.
  • Avoid UI-specific code – Use cases should never know about BuildContext, Navigator, etc.

Common Mistakes

  • ❌ Putting too much logic in a single use case – Violates single responsibility; split into smaller use cases.
  • ❌ Returning data source exceptions directly – Wrap them in meaningful failures.
  • ❌ Using use cases for trivial operations – Adds unnecessary indirection for simple calls.
  • ❌ Storing state in use cases – Use cases should be stateless; keep state in blocs or repositories.
  • ❌ Calling use cases from the UI directly – Use cases should be called by blocs, not directly from widgets.

What's Next?

Now that you have a solid domain layer, explore how to integrate it with the repository pattern and dependency injection to build a complete Clean Architecture app.

Next, explore Repository pattern with Bloc and Clean Architecture with Bloc.

Test Your Knowledge

Q1
of 3

What is the main responsibility of a use case in a Bloc application?

A
Managing UI state
B
Orchestrating business logic and repositories
C
Handling HTTP requests
D
Providing dependency injection
Q2
of 3

Which of the following is a benefit of using a use case layer?

A
Faster app startup
B
Improved testability of business logic
C
Automatic navigation handling
D
Built-in state persistence
Q3
of 3

What should a use case typically return?

A
A Flutter Widget
B
A Stream of events
C
A result type (success/failure) or throw exceptions
D
A BuildContext

Frequently Asked Questions

What's the difference between a use case and a repository?

A repository is responsible for data operations (CRUD) and abstracts data sources. A use case contains business logic and orchestrates one or more repositories. For example, a TransferMoneyUseCase might call AccountRepository to check balance, then call TransactionRepository to perform the transfer.

Should every bloc have its own use cases?

Not necessarily. You can share use cases across blocs. If two blocs need the same business rule (e.g., FetchUserProfile), you can inject the same use case into both blocs.

Can I use use cases with Cubit?

Yes, absolutely. Cubits can also depend on use cases. The pattern is the same – the cubit calls the use case and emits states based on the result.

How do I handle errors in use cases?

There are two common approaches: (1) throw exceptions and catch them in the bloc, or (2) return a result type (like Either<Failure, Success>). The result type approach is more explicit and makes the possible outcomes clear in the method signature.

Do I need a use case interface?

Not always. If you're not planning to have multiple implementations or mock for testing, you can skip the interface. However, having an abstract class (e.g., abstract class LoginUseCase) can make testing easier if you want to mock the use case itself.

Previous

bloc repository pattern

Next

bloc feature based structure

Related Content

Need help?

Explore our comprehensive docs or start a chat with our tech experts.