Command-Query Separation: a design pattern for your Flutter Apps

Angelo Cassano
10 min readMar 7, 2025

--

Event-driven programming and declarative UI frameworks like Flutter have changed the developer experience of people approaching app development.

When moving from an imperative approach to a more declarative way of doing things, you must reconsider how your app exchanges data between different app layers, especially when transitioning information from the business logic layer to the presentation layer and vice-versa.

Also, event-driven programming leads to asynchronous programming. This means you are writing methods that take data as input, and return futures or streams of something else as output most of the time.

Futures versus Streams

Before trying to understand what the Command-Query Separation is and how this pattern could help us write better data-driven Flutter Apps, let’s focus on both the definition of Futures and Streams.

According to the official Flutter documentation:

A Future represents a computation that doesn’t complete immediately. Where a normal function returns the result, an asynchronous function returns a Future, which will eventually contain the result. The future will tell you when the result is ready.

A stream is a sequence of asynchronous events. It is like an asynchronous Iterable — where, instead of getting the next event when you ask for it, the stream tells you that there is an event when it is ready.

Let’s focus on this sentence:

instead of getting the next event when you ask for it, the stream tells you that there is an event when it is ready

Reading between the lines looks like the future could be represented by a voluntary action while the stream might be something we undergo.

Let me explain…

Memo App

Imagine you want to develop a Memo App. What you need are two user interfaces: the first one is where you can show the saved memos sorted by the creation date, and the second one is used to gather information like a title or a description of the memo to be saved.

The best place to save the memos is in a database. We don’t need to do rocket science, then a SQLite database would do the job. We’re going to create a table that contains an auto-increment primary key, two columns representing the title and the description of the memo, and two more columns to store the creation date and the deletion date of the memo itself.

In our memo app, we need to perform three different operations:

  • gathering data from the database: basically, we need to query our SQLite database to get all the non-deleted memos
  • saving a memo to the database: creating a new row entry by providing a title and a description
  • removing a memo from the database: removing a row by a memo ID

In our first interface, all we have to do is call a function that gathers data from the database. But what happens after we create or delete a memo? We have to query our database once again to update our list accordingly.

Is there a way to decouple the operations that read from the database to the ones who write to the database? Yes, we can, using the Command-Query Separation pattern.

Command-Query Separation

Command-Query Separation (or CQS) is a design pattern that separates data requests from data modifications.

A Command represents a request change of stored data through operations like Insert, Update, or Delete. The sole responsibility of a command is to update data, which means no data should be returned.

Future<void> save({required String title, required String description}) =>
into(memosTable).insert(
MemosTableCompanion(
title: Value(title),
description: Value(description),
),
mode: InsertMode.insertOrReplace,
);

Future<void> remove(int id) async {
final query = update(memosTable);

query.where((tbl) => tbl.id.equals(id));

await query.write(MemosTableCompanion(deletedAt: Value(DateTime.now())));
}

On the other hand, a Query is a request to a data source to obtain information about an Entity. Query operations return Data Transfer Objects (DTOs) instead.

Stream<List<MemoEntity>> fetch() {
final query = select(memosTable);

query.where((tbl) => tbl.deletedAt.isNull());
query.orderBy([
(t) => OrderingTerm(expression: t.createdAt, mode: OrderingMode.desc),
]);

return query.watch();
}

Advantages of CQS in Flutter

Command-Query Separation pattern is extremely helpful in event-driven applications, and Flutter extensively relies on event-driven programming.

By declaring all our command methods as functions that return nothing (void) and our query methods as streams that return a DTO or a collection of DTOs, we can easily apply the CQS pattern.

Using the CQS pattern, all we have to do is focus on queries and commands:

  • With a stream that constantly listens for changes coming from our data source, we don’t need to take care of refreshing our memos list when a new memo is created or deleted, the query is already in charge of updating the UI every time something changes.
  • When we create a new memo or delete an existing one, we don’t need to update that specific entry in our result set, the command’s sole responsibility is to update the data.

Memo App: Practical Approach

To better showcase an example of how to use the Command-Query Separation design pattern in our Flutter App, let’s create a new Flutter project using Drift as a reactive persistence library, built on top of SQLite.

After adding Drift dependencies, we need to define our Memo entity to be saved in our database.

import 'package:drift/drift.dart';

@DataClassName('MemoEntity')
class MemosTable extends Table {
Column<int> get id => integer().autoIncrement()();

Column<String> get title => text()();

Column<String> get description => text()();

Column<DateTime> get createdAt =>
dateTime().named('created_at').withDefault(currentDateAndTime)();

Column<DateTime> get deletedAt => dateTime().named('deleted_at').nullable()();

@override
String get tableName => 'memos';
}

Then a Data Access Object class is needed to perform our command and query operations on our SQLite database. Let’s keep in mind that command methods must always return void, and query methods must return the DTOs, in this case represented by the MemoEntity. Usually, it’s better to separate the commands DAO class from the query DAO class, but for the sake of simplicity, we’ll keep everything in a single class.

import 'package:drift/drift.dart';
import 'package:memo_app/features/database/dao/memo/memo_dao.drift.dart';
import 'package:memo_app/features/database/memo_database.dart';
import 'package:memo_app/features/database/tables/memo/memos_table.dart';
import 'package:memo_app/features/database/tables/memo/memos_table.drift.dart';

@DriftAccessor(tables: [MemosTable])
class MemoDAO extends DatabaseAccessor<MemoDatabase> with $MemoDAOMixin {
MemoDAO(super.attachedDatabase);

Future<void> save({required String title, required String description}) =>
into(memosTable).insert(
MemosTableCompanion(
title: Value(title),
description: Value(description),
),
mode: InsertMode.insertOrReplace,
);

Future<void> remove(int id) async {
final query = update(memosTable);

query.where((tbl) => tbl.id.equals(id));

await query.write(MemosTableCompanion(deletedAt: Value(DateTime.now())));
}

Stream<List<MemoEntity>> fetch() {
final query = select(memosTable);

query.where((tbl) => tbl.deletedAt.isNull());
query.orderBy([
(t) => OrderingTerm(expression: t.createdAt, mode: OrderingMode.desc),
]);

return query.watch();
}
}

Let’s move on by creating our MemoRepository, our single source of truth capable of interacting with our data source. Nothing special here, we can spot the very same methods under a different name and with different types: here’s where the DTOs are translated into business logic models.

import 'package:memo_app/features/database/dao/memo/memo_dao.dart';
import 'package:memo_app/models/memo/memo.dart';
import 'package:memo_app/repositories/mappers/memo_mapper.dart';
import 'package:memo_app/repositories/repository.dart';

/// Abstract class of MemoRepository
abstract interface class MemoRepository {
Future<void> create({required String title, required String description});

Stream<List<Memo>> fetch();

Future<void> remove(int id);
}

/// Implementation of the base interface MemoRepository
class MemoRepositoryImpl extends Repository implements MemoRepository {
final MemoDAO memoDAO;
final MemoMapper memoMapper;

const MemoRepositoryImpl({
required this.memoDAO,
required this.memoMapper,
required super.logger,
});

@override
Future<void> create({required String title, required String description}) =>
safeCode(() async {
try {
logger.info('[$MemoRepository] Creating memo with title: $title');

await memoDAO.save(title: title, description: description);

logger.info('[$MemoRepository] Memo created!');
} catch (error, stackTrace) {
logger.error(
'[$MemoRepository] Error creating memo',
error,
stackTrace,
);
rethrow;
}
});

@override
Stream<List<Memo>> fetch() {
logger.info('[$MemoRepository] Fetching memos from database');

final entities = memoDAO.fetch();

return entities.map((entity) {
logger.info('[$MemoRepository] Mapping entities to models');

return memoMapper.toModels(entity);
}).safeCode();
}

@override
Future<void> remove(int id) => safeCode(() async {
try {
logger.info('[$MemoRepository] Removing memo with id: $id');

await memoDAO.remove(id);

logger.info('[$MemoRepository] Memo removed!');
} catch (error, stackTrace) {
logger.error('[$MemoRepository] Error removing memo', error, stackTrace);
rethrow;
}
});
}

And here’s where the magic happens! One layer before the presentation layer, we can spot our state management pattern in charge of translating business logic data into events. In this particular example, I decided to use both BLoC and Cubit, but you can use your favorite state management pattern as well.

In my humble opinion, I believe BLoC and Cubits are well suited to implement the CQS pattern: BLoC is a Cubit with events, and we can associate events with commands! On the other hand, Cubit is pretty simple and we can use it to subscribe to incoming events using a stream subscription.

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_essentials_kit/flutter_essentials_kit.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:memo_app/errors/generic_error.dart';
import 'package:memo_app/repositories/memo_repository.dart';

part 'memo_bloc.freezed.dart';
part 'memo_event.dart';
part 'memo_state.dart';

/// The MemoBloc
class MemoBloc extends Bloc<MemoEvent, MemoState> {
final MemoRepository memoRepository;

/// Create a new instance of [MemoBloc].
MemoBloc({required this.memoRepository}) : super(const MemoState.initial()) {
on<CreateMemoEvent>(_onCreate);
on<RemoveMemoEvent>(_onRemove);
}

/// Method used to add the [CreateMemoEvent] event
void create({required String title, required String description}) =>
add(MemoEvent.create(title: title, description: description));

/// Method used to add the [RemoveMemoEvent] event
void remove(int id) => add(MemoEvent.remove(id));

FutureOr<void> _onCreate(
CreateMemoEvent event,
Emitter<MemoState> emit,
) async {
emit(const MemoState.creating());

try {
await memoRepository.create(
title: event.title,
description: event.description,
);

emit(const MemoState.created());
} on LocalizedError catch (error) {
emit(MemoState.errorCreating(error));
} catch (_) {
emit(MemoState.errorCreating(GenericError()));
}
}

FutureOr<void> _onRemove(
RemoveMemoEvent event,
Emitter<MemoState> emit,
) async {
emit(const MemoState.removing());

try {
await memoRepository.remove(event.id);

emit(const MemoState.removed());
} on LocalizedError catch (error) {
emit(MemoState.errorRemoving(error));
} catch (_) {
emit(MemoState.errorRemoving(GenericError()));
}
}
}

extension MemoBlocExtension on BuildContext {
/// Extension method used to get the [MemoBloc] instance
MemoBloc get memoBloc => read<MemoBloc>();
}
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_essentials_kit/flutter_essentials_kit.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:memo_app/errors/generic_error.dart';
import 'package:memo_app/models/memo/memo.dart';
import 'package:memo_app/repositories/memo_repository.dart';

part 'memo_cubit.freezed.dart';
part 'memo_state.dart';

/// The MemoCubit
class MemoCubit extends Cubit<MemoState> {
final MemoRepository memoRepository;

StreamSubscription<List<Memo>>? _subscription;

/// Create a new instance of [MemoCubit].
MemoCubit({required this.memoRepository}) : super(const MemoState.fetching());

/// Method used to perform the [fetch] action
void fetch() {
_subscription = memoRepository.fetch().listen(
(memos) {
emit(
memos.isNotEmpty ? MemoState.fetched(memos) : const MemoState.empty(),
);
},
onError: (error) {
if (error is LocalizedError) {
emit(MemoState.errorFetching(error));
} else {
emit(MemoState.errorFetching(GenericError()));
}
},
);
}

@override
Future<void> close() {
_subscription?.cancel();

return super.close();
}
}

extension MemoCubitExtension on BuildContext {
/// Extension method used to get the [MemoCubit] instance
MemoCubit get memoCubit => read<MemoCubit>();
}

Now let’s move to the presentation layer to better understand how things (do not) interact with each other, making the CQS pattern concrete.

As we can see, the only thing MainPage class is doing is subscribing for upcoming state changes from MemoCubit, nothing less, nothing more. Even though there are two buttons in charge of navigating to the creation page, we’re not telling anyone to reload our data once deletion or creation is done.

import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:memo_app/blocs/memo/memo_bloc.dart';
import 'package:memo_app/cubits/memo/memo_cubit.dart' as cubit;
import 'package:memo_app/features/localization/extensions/build_context.dart';
import 'package:memo_app/features/routing/app_router.dart';
import 'package:memo_app/models/memo/memo.dart';
import 'package:memo_app/pages/main/widgets/add_fab.dart';
import 'package:memo_app/pages/main/widgets/empty_memo_courtesy.dart';
import 'package:memo_app/pages/main/widgets/error_memo_courtesy.dart';
import 'package:memo_app/pages/main/widgets/memo_card.dart';
import 'package:memo_app/widgets/loading_widget.dart';

/// Enter the Main documentation here
@RoutePage()
class MainPage extends StatelessWidget implements AutoRouteWrapper {
/// The constructor of the page.
const MainPage({super.key});

@override
Widget wrappedRoute(BuildContext context) => MultiBlocProvider(
providers: [
BlocProvider<MemoBloc>(
create: (context) => MemoBloc(memoRepository: context.read()),
),
BlocProvider<cubit.MemoCubit>(
create:
(context) =>
cubit.MemoCubit(memoRepository: context.read())..fetch(),
),
],
child: this,
);

@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: Text(context.l10n?.appName ?? 'appName')),
body: BlocBuilder<cubit.MemoCubit, cubit.MemoState>(
builder:
(context, state) => switch (state) {
cubit.FetchingMemoState() => const LoadingWidget(),
cubit.FetchedMemoState(:final memos) => ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 16.0),
physics: const BouncingScrollPhysics(),
separatorBuilder: (_, __ )=> const SizedBox(height: 8.0),
itemBuilder: (context, index) {
final memo = memos[index];

return MemoCard(
memo,
onDismissed: () => _removeMemo(context, memo),
);
},
itemCount: memos.length,
),
cubit.EmptyMemoState() => EmptyMemoCourtesy(
onTap: () => context.router.push(const CreateMemoRoute()),
),
cubit.ErrorFetchingMemoState() => const ErrorMemoCourtesy(),
},
),
floatingActionButton: BlocSelector<cubit.MemoCubit, cubit.MemoState, bool>(
selector:
(state) => switch (state) {
cubit.FetchedMemoState() => true,
_ => false,
},
builder:
(context, showButton) => switch (showButton) {
true => AddFab(
onPressed: () => context.router.push(const CreateMemoRoute()),
),
false => const SizedBox.shrink(),
},
),
);

void _removeMemo(BuildContext context, Memo memo) {
context.memoBloc.remove(memo.id);
}
}

Of course, we can see there’s another bloc on this page, but its only purpose is to trigger our deletion command, which will propagate down the layers until MemoDAO’s remove function which will set deletedAt value to DateTime.now().

import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:memo_app/blocs/memo/memo_bloc.dart';
import 'package:memo_app/features/localization/extensions/build_context.dart';
import 'package:memo_app/pages/create_memo/widgets/create_button.dart';
import 'package:memo_app/pages/create_memo/widgets/reactive_description_field.dart';
import 'package:memo_app/pages/create_memo/widgets/reactive_title_field.dart';
import 'package:memo_app/widgets/loading_widget.dart';
import 'package:reactive_forms/reactive_forms.dart';

/// Enter the CreateMemo documentation here
@RoutePage()
class CreateMemoPage extends StatefulWidget implements AutoRouteWrapper {
static const _kFormTitle = 'title';
static const _kFormDescription = 'description';

/// The constructor of the page.
const CreateMemoPage({super.key});

@override
Widget wrappedRoute(BuildContext context) => MultiBlocProvider(
providers: [
BlocProvider<MemoBloc>(
create: (context) => MemoBloc(memoRepository: context.read()),
),
],
child: this,
);

@override
State<CreateMemoPage> createState() => _CreateMemoState();
}

class _CreateMemoState extends State<CreateMemoPage> {
final _form = FormGroup({
CreateMemoPage._kFormTitle: FormControl<String>(
validators: [Validators.required],
),
CreateMemoPage._kFormDescription: FormControl<String>(
validators: [Validators.required],
),
});

@override
Widget build(BuildContext context) => BlocListener<MemoBloc, MemoState>(
listener:
(context, state) => switch (state) {
CreatingMemoState() => _onCreating(context),
CreatedMemoState() => _onCreated(context),
ErrorCreatingMemoState() => _onErrorCreating(context),
_ => null,
},
child: Scaffold(
appBar: AppBar(
title: Text(context.l10n?.titleCreateMemo ?? 'titleCreateMemo'),
),
body: ReactiveForm(
formGroup: _form,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
physics: const BouncingScrollPhysics(),
children: [
const ReactiveTitleField(
formControlName: CreateMemoPage._kFormTitle,
),
const ReactiveDescriptionField(
formControlName: CreateMemoPage._kFormDescription,
),
BlocBuilder<MemoBloc, MemoState>(
builder:
(context, state) => switch (state) {
CreatingMemoState() => const LoadingWidget(),
_ => CreateButton(onPressed: () => _createMemo(context)),
},
),
],
),
),
),
);

void _createMemo(BuildContext context) {
final title = _form.control(CreateMemoPage._kFormTitle).value;
final description = _form.control(CreateMemoPage._kFormDescription).value;

context.memoBloc.create(title: title, description: description);
}

void _onCreating(BuildContext context) {
_form.markAsDisabled();
}

void _onCreated(BuildContext context) {
_form.markAsEnabled();

context.maybePop();
}

void _onErrorCreating(BuildContext context) {
_form.markAsEnabled();

ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n?.snackbarErrorCreatingMemo ??
'snackbarErrorCreatingMemo',
),
),
);
}
}

The same approach applies to the CreateMemoPage, a single cubit injected in the tree, capable of creating a new memo entry in our SQLite database.

Third-party project libraries

Many different third-party dependencies have been used to support this project like auto_route, dart_mapper, freezed, pine, and reactive_forms. If you want to better understand how this project works, here you can find the official repository on GitHub.

Conclusion

In this article, we delved into how to apply the Command-Query Separation pattern to write better data-driven Flutter Apps. CQS pattern could be really helpful when developing applications that rely on a lot of CRUD operations, especially when using a database like SQLite. Libraries like Drift help us write better CQS-driven applications focusing on what matters: data gathering and modifications.

--

--

Angelo Cassano
Angelo Cassano

Written by Angelo Cassano

A perfectionist. Software engineer and IT passionate.

No responses yet