DEV Community

Emin Şahin
Emin Şahin

Posted on

CQRS Pattern in Flutter: Commands vs Queries

When building Flutter applications with Clean Architecture, one pattern that dramatically improves code clarity is Command Query Responsibility Segregation (CQRS).

In this article, I'll show you how I implement CQRS in a real Flutter project.

What is CQRS?

CQRS separates read operations (Queries) from write operations (Commands):

Type Purpose Side Effects
Command Mutate state (create, update, delete) Yes
Query Read data No

This separation makes your code:

  • ✅ Easier to test (queries are always idempotent)
  • ✅ Clearer intent (naming says what it does)
  • ✅ Simpler to maintain (each class has one responsibility)

Base Classes

Here are the base classes I use:

Command (Write Operations)

/// Base class for commands that mutate state
abstract class Command<Params, Output> {
  const Command();

  FutureResult<Output> call(Params params);
}

/// Command without parameters
abstract class CommandNoParams<Output> {
  const CommandNoParams();

  FutureResult<Output> call();
}
Enter fullscreen mode Exit fullscreen mode

Query (Read Operations)

/// Base class for queries that read data
abstract class Query<Params, Output> {
  const Query();

  FutureResult<Output> call(Params params);
}

/// Query without parameters
abstract class QueryNoParams<Output> {
  const QueryNoParams();

  FutureResult<Output> call();
}
Enter fullscreen mode Exit fullscreen mode

Real Examples from My Auth Feature

Command: Login

Login is a Command because it mutates state by creating an authenticated session:

@injectable
class Login extends Command<AuthCredentials, User> {
  const Login(this._repository, this._eventDispatcher);

  final IAuthRepository _repository;
  final IEventDispatcher _eventDispatcher;

  @override
  FutureResult<User> call(AuthCredentials credentials) async {
    // Mutate state: authenticate user
    final result = await _repository.login(credentials);

    // Dispatch domain event on success
    return result.map((user) {
      _eventDispatcher.dispatch(UserLoggedIn(user));
      return user;
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Query: CheckUserExists

CheckUserExists is a Query because it only reads data:

@injectable
class CheckUserExists extends Query<EmailAddress, bool> {
  const CheckUserExists(this._repository);

  final IAuthRepository _repository;

  @override
  FutureResult<bool> call(EmailAddress email) async {
    // Read-only: check if user exists
    return _repository.checkUserExists(email);
  }
}
Enter fullscreen mode Exit fullscreen mode

Query: GetCurrentUser

@injectable
class GetCurrentUser extends QueryNoParams<User?> {
  const GetCurrentUser(this._repository, this._eventDispatcher);

  final IAuthRepository _repository;
  final IEventDispatcher _eventDispatcher;

  @override
  FutureResult<User?> call() async {
    // Read-only: get current user from cache/backend
    final result = await _repository.getCurrentUser();

    return result.map((user) {
      if (user != null) {
        _eventDispatcher.dispatch(UserSessionRestored(user));
      }
      return user;
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

Stream Variants

For reactive operations, I also have stream-based variants:

// Stream query - watch data changes
abstract class StreamQuery<Params, Output> {
  StreamResult<Output> call(Params params);
}

// Stream command - reactive write operations
abstract class StreamCommand<Params, Output> {
  StreamResult<Output> call(Params params);
}
Enter fullscreen mode Exit fullscreen mode

Example: WatchAuthChanges is a StreamQueryNoParams<User?> that emits whenever the auth state changes.

How to Use in BLoC

class AuthBloc extends Bloc<AuthEvent, AuthState> {
  AuthBloc(this._login, this._checkUserExists, this._getCurrentUser);

  final Login _login;
  final CheckUserExists _checkUserExists;
  final GetCurrentUser _getCurrentUser;

  Future<void> _onSubmitCredentials(event, emit) async {
    // Use Command for login
    final result = await _login(AuthCredentials(
      email: event.email,
      password: event.password,
    ));

    result.fold(
      (failure) => emit(AuthState.error(failure)),
      (user) => emit(AuthState.authenticated(user)),
    );
  }

  Future<void> _onEmailSubmitted(event, emit) async {
    // Use Query for checking user
    final result = await _checkUserExists(event.email);

    result.fold(
      (failure) => emit(AuthState.error(failure)),
      (exists) => exists
        ? emit(AuthState.loginRequired(event.email))
        : emit(AuthState.registrationRequired(event.email)),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Benefits I've Seen

  1. Clear Intent: Just by reading Login extends Command<...>, you know it mutates state
  2. Testability: Queries are pure - same input = same output
  3. SOLID Compliance: Each use case has a single responsibility
  4. Discoverability: Team members can easily find all write vs read operations

Conclusion

CQRS isn't just for backend systems. In Flutter with Clean Architecture, it brings clarity and maintainability to your use case layer.

The key insight: Name your operations by what they DO, and type them by their NATURE (read vs write).


This is part of my AI-Ready Flutter Enterprise Starter series. Follow @deveminsahin for more architecture patterns!

flutter #dart #cleanarchitecture #cqrs

Top comments (0)