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();
}
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();
}
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;
});
}
}
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);
}
}
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;
});
}
}
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);
}
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)),
);
}
}
Benefits I've Seen
-
Clear Intent: Just by reading
Login extends Command<...>, you know it mutates state - Testability: Queries are pure - same input = same output
- SOLID Compliance: Each use case has a single responsibility
- 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!
Top comments (0)