@freeCodeCamp: When one event needs to trigger multiple reactions, tightly coupled code can quickly get hard to maintain. In this hand…
Summary
A handbook explaining the Observer design pattern, its implementation in Dart, and how it integrates with event-driven architecture and Domain-Driven Design in Flutter applications.
View Cached Full Text
Cached at: 07/22/26, 04:21 AM
When one event needs to trigger multiple reactions, tightly coupled code can quickly get hard to maintain.
In this handbook, @devseyi explains the Observer design pattern and shows you how to implement it in Dart.
You’ll also learn how it connects to event-driven architecture, Domain-Driven Design, and production-ready Flutter apps.
https://freecodecamp.org/news/the-observer-design-pattern-handbook-event-driven-architecture-domain-driven-design-in-dart/…
The Observer Design Pattern Handbook: Event-Driven Architecture & Domain-Driven Design in Dart
Source: https://www.freecodecamp.org/news/the-observer-design-pattern-handbook-event-driven-architecture-domain-driven-design-in-dart/
Every application, at some point, has to deal with a fundamental challenge: something happens, and several other things need to react to it.
A user logs in, and the app needs to save a token, cache the user profile, fire an analytics event, and navigate to the home screen.
A payment is confirmed, and the inventory needs to update, the user needs a receipt, and the fulfillment system needs to kick off delivery.
A sensor reading changes, and three different UI panels need to reflect the new value simultaneously.
The naïve solution is to write all of that logic in one place. One function that does everything or one class that knows about everything.
This works at first. Then requirements change. A new reaction needs to be added. An existing one needs to be removed. A side effect starts failing and takes everything else down with it. The code becomes a wall of responsibilities that’s impossible to test, painful to extend, and dangerous to touch.
The Observer Design Pattern exists to solve exactly this problem. It gives you a structured, production-grade way to say: when this event happens, notify everyone who cares, without the event source knowing who those people are.
In this handbook, you’ll learn the Observer pattern from first principles. You’ll see how it’s implemented in Dart, understand how it connects to Event-Driven Architecture, and discover how it integrates cleanly with Domain-Driven Design and Riverpod in a real Flutter application.
By the end, you won’t just understand the pattern. You’ll know how to use it deliberately in production code.
Table of Contents
- What is the Observer Design Pattern?
- The Problem It Solves
- Core Components
- Implementing Observer in Dart
- A Real-World Example: The Login Flow
- Making It Production-Grade with a Generic EventBus
- Observer Is Already in Your Flutter Code
- Deep Dive Into Event-Driven Architecture
- Application in Domain-Driven Design
- The Riverpod Hybrid: Clean Architecture in Practice
- Testing the Observer Architecture
- When to Use the Observer Pattern
- When Not to Use It
- Conclusion
The Observer pattern is a behavioural design pattern that defines a one-to-many dependency between objects. When one object changes state or fires an event, all of its dependents are notified and updated automatically.
Think of a newspaper subscription service. The newspaper publisher doesn’t know who its individual subscribers are. It doesn’t call each reader personally. It publishes the paper, and every subscriber who signed up receives it.
A subscriber can cancel at any time. A new subscriber can join at any time. The publisher’s job never changes. It just publishes.
That’s the Observer pattern in plain terms.
The publisher is called theSubject. The subscribers are calledObservers. The newspaper is theevent.
The pattern was formally defined in the Gang of Four book, Design Patterns: Elements of Reusable Object-Oriented Software. It remains one of the most widely used patterns in software engineering, especially in reactive and event-driven systems.
The Problem It Solves
Let’s look at what happens without the Observer pattern.
Say you have a login feature. When login succeeds, you need to do four things:
- Save the authentication token to secure storage
- Cache the user profile data
- Navigate to the home screen
- Fire an analytics event
The straightforward approach puts all of this inside the login function:
Future<void> login(String email, String password) async {
final response = await _authRepository.login(email, password);
await _secureStorage.write(key: 'token', value: response.token);
await _userCache.save(response.user);
_navigationService.navigateTo('/home');
_analytics.track('login_success', {'userId': response.user.id});
}
This looks fine at first glance. But count how many reasons this single function has to change:
- The token storage strategy changes. You modify this function.
- The navigation destination changes. You modify this function.
- The analytics event name or payload changes. You modify this function.
- The user caching logic changes. You modify this function.
Every single change to any of these four concerns forces you back into this one function. And every time you touch it, you risk breaking all the other three things it’s doing.
Now imagine you need to add a fifth thing, such as enrolling the user in push notifications. You open this function again. You add more code. The function grows. Testing it requires mocking four, then five different dependencies. New teammates struggle to understand what this function is actually responsible for. The answer, of course, is everything. And that’s the problem.
This is called tight coupling. The login logic is coupled to every single consequence of a successful login.
The Observer pattern breaks these couplings completely. The login logic does one thing: it performs the login and announces the result. Every consequence is handled by a separate, independent observer. Each observer has one job. None of them know about each other. The login logic doesn’t know they exist.
Core Components
The Observer pattern has four core building blocks. Understanding each one before writing code makes the implementation much easier to follow.
Subject
The Subject is the object that something happens to. It holds a list of observers and is responsible for notifying them when an event occurs. It exposes methods for observers to register and unregister themselves. The Subject doesn’t care what observers do with the notification. It just delivers it.
Observer
The Observer is an interface or abstract class that defines the contract all observers must follow. It declares the method or methods the Subject will call when notifying. Any class that wants to react to an event must implement this interface.
Concrete Subject
The Concrete Subject is the real implementation of the Subject. It manages the actual list of observers, handles subscriptions, and fires notifications at the right moment.
Concrete Observers
These are the real classes that implement the Observer interface. Each one has a specific, focused job to do when notified. One saves the token. One navigates. One fires analytics. They don’t know about each other and don’t need to.
Here’s how they relate to each other:
Subject (LoginService)
|
|-- subscribe(observer) <- observer registers itself
|-- unsubscribe(observer) <- observer removes itself
|-- notifySuccess(data) <- fires when login succeeds
|-- notifyFailure(error) <- fires when login fails
|
|-----> TokenObserver.onLoginSuccess()
|-----> UserObserver.onLoginSuccess()
|-----> NavigationObserver.onLoginSuccess()
|-----> AnalyticsObserver.onLoginSuccess()
The Subject notifies all of them. They each handle their own job independently.
Implementing Observer in Dart
Let’s build the pattern step by step.
Step 1: Define the Observer Interface
abstract class LoginObserver {
void onLoginSuccess(UserDto user);
void onLoginFailed(AppException error);
}
This is the contract that every observer must sign. Any class that wants to react to login events must implement both of these methods.
onLoginSuccessis called when the login succeeds and receives the user data.onLoginFailedis called when the login fails and receives the error.
Step 2: Define the Subject Interface
abstract class LoginSubject {
void subscribe(LoginObserver observer);
void unsubscribe(LoginObserver observer);
void notifySuccess(UserDto user);
void notifyFailure(AppException error);
}
subscribelets an observer join the notification list.unsubscribelets an observer leave the notification list.notifySuccessbroadcasts a success event with the user data to all registered observers.notifyFailurebroadcasts a failure event with the error to all registered observers.
Defining this as an abstract class instead of going straight to a concrete class is important. It means anything that depends on the subject depends on the abstraction, not the implementation. This makes your code testable and swappable.
Step 3: Implement the Concrete Subject
class LoginService implements LoginSubject {
final List<LoginObserver> _observers = [];
@override
void subscribe(LoginObserver observer) {
_observers.add(observer);
}
@override
void unsubscribe(LoginObserver observer) {
_observers.remove(observer);
}
@override
void notifySuccess(UserDto user) {
for (final observer in List.of(_observers)) {
try {
observer.onLoginSuccess(user);
} catch (e) {
debugPrint('Observer error on success: $e');
}
}
}
@override
void notifyFailure(AppException error) {
for (final observer in List.of(_observers)) {
try {
observer.onLoginFailed(error);
} catch (e) {
debugPrint('Observer error on failure: $e');
}
}
}
}
There are two important decisions in this implementation that are easy to miss.
1. Snapshot iteration withList\.of\(\)
Instead of iterating directly over\_observers, we iterate overList\.of\(\_observers\), which creates a copy of the list before the loop runs.
Why does this matter? Imagine aNavigationObserverthat, after navigating to the home screen, unsubscribes itself because it no longer needs to listen. If it callsunsubscribewhile thenotifySuccessloop is still running over the same list, Dart throws aConcurrentModificationError. The list is being modified while it’s being read.
List\.of\(\)prevents this entirely. The loop runs over the snapshot. The original list can be modified freely during iteration without any errors.
2. Per-observer try/catch
Each observer call is wrapped in its own try/catch block. This is a deliberate choice. IfTokenObserverthrows an exception while writing to secure storage, you don’t wantNavigationObserverandAnalyticsObserverto silently never fire. Each observer gets its chance to run regardless of what the others do.
Without this, one failing observer would stop the entire notification chain. That’s a hidden bug that’s extremely difficult to trace in production.
A Real-World Example: The Login Flow
Now let’s build the full login flow using this foundation.
The Login Logic
class LoginLogic {
final LoginSubject _subject;
final AuthRepository _repository;
LoginLogic({
required LoginSubject subject,
required AuthRepository repository,
}) : _subject = subject,
_repository = repository;
Future<void> callLogin(LoginRequest request) async {
try {
final user = await _repository.login(request);
_subject.notifySuccess(user);
} on AppException catch (e) {
_subject.notifyFailure(e);
} catch (e) {
_subject.notifyFailure(AppException.unknown(message: e.toString()));
}
}
}
Let’s walk through this carefully.
LoginLogictakes two dependencies through its constructor: aLoginSubjectand anAuthRepository. Notice it takesLoginSubject, the abstraction, notLoginService, the concrete class. This means you can swap the implementation in tests or in different environments without changingLoginLogicat all.
InsidecallLogin, the logic is straightforward. It calls the repository to perform the actual login. If that succeeds, it callsnotifySuccesson the subject with the returned user. If it throws anAppException, it callsnotifyFailurewith that error. If it throws anything unexpected, it wraps it in anAppException\.unknownand notifies failure.
Notice whatLoginLogicdoes NOT do. It doesn’t save a token. It doesn’t navigate anywhere. It doesn’t cache anything. It doesn’t fire analytics. And it doesn’t know how many observers exist or what they do.
Its entire responsibility is: perform the login, announce the result.
The Concrete Observers
class TokenObserver implements LoginObserver {
final SecureStorageService _storage;
TokenObserver(this._storage);
@override
void onLoginSuccess(UserDto user) {
_storage.write(key: 'auth_token', value: user.token);
}
@override
void onLoginFailed(AppException error) {
_storage.delete(key: 'auth_token');
}
}
TokenObserverhas one job: manage the authentication token. On success, it saves the token. On failure, it clears any stale token that might be sitting in storage. It knows nothing about navigation, caching, or analytics.
class UserObserver implements LoginObserver {
final UserCacheService _cache;
UserObserver(this._cache);
@override
void onLoginSuccess(UserDto user) {
_cache.save(user);
}
@override
void onLoginFailed(AppException error) {
_cache.clear();
}
}
UserObserverhas one job: manage the user cache. On success, it saves the user profile. On failure, it clears the cache. It knows nothing about tokens, navigation, or analytics.
class NavigationObserver implements LoginObserver {
final NavigationService _navigation;
NavigationObserver(this._navigation);
@override
void onLoginSuccess(UserDto user) {
_navigation.navigateTo('/home');
}
@override
void onLoginFailed(AppException error) {
_navigation.showError(error.message);
}
}
NavigationObserverhas one job: handle navigation after a login attempt. It uses an injectedNavigationServiceabstraction rather than aBuildContext. This is intentional. An observer that depends onBuildContextis tied to the widget lifecycle. Using an abstraction keeps this observer completely independent of the UI layer.
class AnalyticsObserver implements LoginObserver {
final AnalyticsService _analytics;
AnalyticsObserver(this._analytics);
@override
void onLoginSuccess(UserDto user) {
_analytics.track('login_success', {'userId': user.id});
}
@override
void onLoginFailed(AppException error) {
_analytics.track('login_failed', {'reason': error.message});
}
}
AnalyticsObserverhas one job: fire the right analytics event for each outcome. It has no knowledge of storage, navigation, or caching.
Each observer has exactly one responsibility. Each one has exactly one reason to change. When the analytics payload needs to change, you touch onlyAnalyticsObserver. When navigation logic changes, you touch onlyNavigationObserver. Nothing else is affected.
Wiring It Together
void setupLogin() {
final service = LoginService();
service
..subscribe(TokenObserver(secureStorage))
..subscribe(UserObserver(userCache))
..subscribe(NavigationObserver(navigationService))
..subscribe(AnalyticsObserver(analyticsService));
final loginLogic = LoginLogic(
subject: service,
repository: authRepository,
);
}
This is the composition step. All observers are created with their dependencies and registered onto the service. The cascade operator\.\.callssubscribemultiple times on the sameserviceobject, which keeps the setup readable.
LoginLogicreceives theserviceas itsLoginSubject. From this point forward, every timecallLoginis called and an outcome occurs, all four observers are notified automatically.
Adding a fifth observer, say aPushNotificationObserver, means creating the class and adding one line here:\.\.subscribe\(PushNotificationObserver\(pushService\)\). Nothing else in the entire codebase changes.
Making It Production-Grade with a Generic EventBus
The login example above works well, but it’s specific to login. In a real application, many features have the same fan-out requirement. Payment confirmed, order placed, profile updated, session expired. All of them need one event to trigger multiple independent reactions.
Rewriting the Subject and Observer interfaces per feature is repetitive and unnecessary. The better approach is a genericEventBusthat any feature can use.
abstract class DomainObserver<T> {
void onSuccess(T data);
void onFailure(AppException error);
}
DomainObserver<T\>is a generic observer. The type parameterTrepresents the data type the observer expects on success. A login observer would beDomainObserver<UserDto\>. A payment observer would beDomainObserver<PaymentDto\>. The interface is the same. The data type changes per feature.
class EventBus<T> {
final List<DomainObserver<T>> _observers = [];
void subscribe(DomainObserver<T> observer) {
_observers.add(observer);
}
void unsubscribe(DomainObserver<T> observer) {
_observers.remove(observer);
}
void publishSuccess(T data) {
for (final observer in List.of(_observers)) {
try {
observer.onSuccess(data);
} catch (e) {
debugPrint('[EventBus] Observer error on success: $e');
}
}
}
void publishFailure(AppException error) {
for (final observer in List.of(_observers)) {
try {
observer.onFailure(error);
} catch (e) {
debugPrint('[EventBus] Observer error on failure: $e');
}
}
}
}
EventBus<T\>is a generic subject. It manages a list of typed observers and notifies them with the same snapshot iteration and per-observer error isolation we established earlier.
Now every feature gets the same infrastructure without duplicating a single line of the pattern:
final loginBus = EventBus<UserDto>();
final paymentBus = EventBus<PaymentDto>();
final orderBus = EventBus<OrderDto>();
Each bus is typed to its domain concept. Observers registered onloginBuswill never accidentally receive payment events. The type system enforces correctness.
Observer Is Already in Your Flutter Code
Before going further into architecture, here’s something worth pausing on. You’ve been using the Observer pattern all along without calling it by that name.
Streams and StreamController:
final controller = StreamController<String>();
controller.stream.listen((event) {
print('Observed: $event');
});
controller.sink.add('Login succeeded');
StreamControlleris a Subject.stream\.listenissubscribe.sink\.addisnotifyObservers. Every stream subscription is an Observer. The pattern is identical. Flutter just gave it different names.
ChangeNotifier:
class CounterModel extends ChangeNotifier {
int _count = 0;
void increment() {
_count++;
notifyListeners();
}
}
notifyListeners\(\)iterates over every registered listener and calls them. Those listeners are Observers.addListenerissubscribe.removeListenerisunsubscribe.ChangeNotifieris a concrete Subject.
BLoC:
When a BLoC emits a new state, every widget that wrapped itself in aBlocBuilderorBlocListenerreacts. The BLoC is the Subject. The builders and listeners are Observers. The state emission is the notification.
Flutter’s entire reactive system (Streams, ChangeNotifier, BLoC, ValueNotifier) is the Observer pattern with lifecycle management built in. Understanding the pattern at this fundamental level means you understand why all of these tools work the way they do. You aren’t just using them. You understand them.
Deep Dive Into Event-Driven Architecture
Understanding Observer at the class level is the foundation. The pattern becomes significantly more powerful when applied at the architectural level, and that’s where Event-Driven Architecture comes in.
What is Event-Driven Architecture?
Event-Driven Architecture is a design paradigm where the flow of the application is determined by events. Instead of components calling each other directly, they communicate by producing and consuming events through a shared bus or channel.
In a traditional request-driven flow, this is what happens:
Component A calls Component B directly
Component B does its work and returns a result
Component A waits for that result and then continues
Component A knows about Component B. It depends on it by name. It waits for it to finish. If you want Component C to also react to whatever Component A is doing, you have to go back into Component A and add that call.
But then Component A grows. Component A becomes responsible for orchestrating consequences it should know nothing about.
In an event-driven flow, this is what happens instead:
Component A publishes an event to the EventBus
EventBus delivers the event to whoever is registered
Component B handles the event
Component C handles the event
Component D handles the event
Component A doesn’t know about B, C, or D. It doesn’t wait for them. It publishes what happened and moves on. New handlers can be added without touching Component A at all. This is the Observer pattern scaled to the architectural level.
Events Are Facts, Not Commands
This distinction is one of the most important concepts in Event-Driven Architecture.
A command says: “do this.” It’s an instruction that can be rejected. It expects a response.
An event says: “this happened.” It’s an immutable record of a fact. It doesn’t expect a response. It doesn’t care who handles it.
SaveUserTokenis a command.UserLoggedInis an event.
When you model your system with events as facts, you get a historical record of everything that happened in your application. You can replay events to reconstruct state. You can add new handlers that process historical events. Your system becomes auditable and predictable in ways that command-driven systems are not.
Modelling Domain Events in Dart
Events should be immutable value objects. They’re facts. Facts don’t change after they happen.
abstract class DomainEvent {
final DateTime occurredAt;
final String eventId;
const DomainEvent({
required this.occurredAt,
required this.eventId,
});
}
DomainEventis the base class for all events in the system. Every event has a timestamp (occurredAt) recording when it happened, and a unique identifier (eventId) for traceability.
class UserLoggedIn extends DomainEvent {
final UserDto user;
const UserLoggedIn({
required this.user,
required super.occurredAt,
required super.eventId,
});
}
class LoginFailed extends DomainEvent {
final AppException error;
const LoginFailed({
required this.error,
required super.occurredAt,
required super.eventId,
});
}
UserLoggedIncarries the user data.LoginFailedcarries the error. Both are immutable. Both have timestamps and identifiers. Both are concrete facts about something that happened in the domain.
A Type-Safe DomainEventBus
Now we can build an event bus that’s typed to domain events specifically:
abstract class EventHandler<T extends DomainEvent> {
void handle(T event);
}
EventHandler<T\>is the Observer interface for this architecture. Any class that wants to handle a domain event implements this with the specific event type it cares about.
class DomainEventBus {
final _handlers = <Type, List<EventHandler>>{};
void register<T extends DomainEvent>(EventHandler<T> handler) {
_handlers.putIfAbsent(T, () => []).add(handler);
}
void publish<T extends DomainEvent>(T event) {
final handlers = List.of(_handlers[T] ?? []);
for (final handler in handlers) {
try {
(handler as EventHandler<T>).handle(event);
} catch (e) {
debugPrint('[DomainEventBus] Handler error for ${T}: $e');
}
}
}
}
Let’s go throughDomainEventBuscarefully.
\_handlersis a map where the key is aType(the event class itself, likeUserLoggedIn) and the value is a list of all handlers registered for that event type.
register<T\>takes a handler and adds it to the list for typeT.putIfAbsentensures the list is created if this is the first handler for that event type.
publish<T\>looks up all handlers registered for the type of event being published and calls each one’shandlemethod. The snapshot withList\.of\(\)and the per-handler try/catch are both present for the same reasons we established earlier.
Here’s how you register handlers and publish events:
// Registration happens once at startup
eventBus.register<UserLoggedIn>(TokenHandler(secureStorage));
eventBus.register<UserLoggedIn>(UserCacheHandler(userCache));
eventBus.register<UserLoggedIn>(NavigationHandler(navigationService));
eventBus.register<UserLoggedIn>(AnalyticsHandler(analyticsService));
eventBus.register<PaymentConfirmed>(ReceiptHandler(receiptService));
eventBus.register<PaymentConfirmed>(InventoryHandler(inventoryService));
// Publishing happens at the use case level
eventBus.publish(UserLoggedIn(
user: user,
occurredAt: DateTime.now(),
eventId: const Uuid().v4(),
));
WhenUserLoggedInis published, only its registered handlers fire. Payment handlers aren’t touched. Every handler forUserLoggedInruns independently with full error isolation.
Application in Domain-Driven Design
Event-Driven Architecture and the Observer pattern find their most structured home inside Domain-Driven Design. DDD gives us the vocabulary and structure to know exactly where events belong, who creates them, and who handles them.
Key DDD Concepts You Need to Know
Domain Eventsare first-class citizens in DDD. They represent something meaningful that happened in the business domain. Not a technical detail, not an HTTP response, but a business fact.
UserLoggedInis a domain event.LoginResponseDtois a data transfer object. The distinction matters deeply. The event belongs to the domain model and expresses business language. The DTO belongs to the data layer and expresses data structure.
Aggregatesare the natural source of domain events. An Aggregate is a cluster of domain objects that form a consistency boundary. The Aggregate enforces business rules and raises domain events when significant state changes occur within it.
Use Casesare the orchestrators. A use case calls the repository, gets the result, raises the appropriate domain event, and returns the outcome. It doesn’t handle side effects directly. It announces what happened and lets the registered handlers take over.
Where Everything Lives in Clean Architecture
lib/
core/
events/
domain_event.dart <- Base DomainEvent class
domain_event_bus.dart <- The DomainEventBus
event_handler.dart <- Base EventHandler interface
features/
auth/
domain/
events/
user_logged_in.dart <- Domain event (pure Dart, no Flutter)
login_failed.dart <- Domain event
handlers/
token_handler.dart <- Handles token storage
user_cache_handler.dart <- Handles user caching
analytics_handler.dart <- Handles analytics
entities/
user.dart
repositories/
auth_repository.dart <- Abstract interface only
usecases/
login_usecase.dart <- Orchestrates, publishes events
data/
repositories/
auth_repository_impl.dart
datasources/
auth_remote_datasource.dart
presentation/
providers/
login_provider.dart <- Riverpod notifier (thin)
pages/
login_page.dart
The critical rule: the domain layer is pure Dart. No Flutter imports. No Riverpod imports. No HTTP imports. TheDomainEventBus, domain events, handlers, and use cases all live in the domain layer and have zero framework dependencies.
This means that the same domain logic works in Flutter, server-side Dart, or a CLI tool without changing a single line. Framework upgrades, say from Riverpod 2.x to a future version, never touch the domain. Unit tests for the domain run in milliseconds with no widget test overhead.
The Login Use Case in DDD
class LoginUseCase {
final AuthRepository _repository;
final DomainEventBus _eventBus;
LoginUseCase({
required AuthRepository repository,
required DomainEventBus eventBus,
}) : _repository = repository,
_eventBus = eventBus;
Future<Result<UserDto, AppException>> execute(LoginRequest request) async {
try {
final user = await _repository.login(request);
_eventBus.publish(UserLoggedIn(
user: user,
occurredAt: DateTime.now(),
eventId: const Uuid().v4(),
));
return Result.success(user);
} on AppException catch (e) {
_eventBus.publish(LoginFailed(
error: e,
occurredAt: DateTime.now(),
eventId: const Uuid().v4(),
));
return Result.failure(e);
}
}
}
Let’s walk through this step by step.
LoginUseCasereceives two dependencies: anAuthRepositoryabstraction and aDomainEventBus. Neither is a concrete class. Both can be swapped in tests.
Insideexecute, it calls the repository to perform the login. If the login succeeds, it publishes aUserLoggedInevent to the bus, which immediately notifies all registered handlers. Then it returns aResult\.successwrapping the user data.
If anAppExceptionis caught, it publishes aLoginFailedevent to the bus, which notifies all failure handlers. Then it returns aResult\.failurewrapping the error.
The use case doesn’t know how many handlers are registered. It doesn’t know what they do. It performs the operation, publishes the outcome as a domain event, and returns the result.
TheResulttype is a return value for the caller (the Riverpod notifier) to know the outcome. The domain event is the broadcast for all side effect handlers. Both travel from the same single use case call. This is what makes the architecture clean.
The Riverpod Hybrid: Clean Architecture in Practice
This is where everything comes together in a real Flutter application.
The Problem We Are Solving
There are two common pain points in Flutter apps that use Riverpod:
Fat ref.listen in widgets:
// This is messy
ref.listen<AsyncValue<UserDto?>>(loginProvider, (previous, next) {
next.whenData((user) {
if (user != null) {
secureStorage.write(key: 'token', value: user.token);
userCache.save(user);
context.go('/home');
analytics.track('login_success');
}
});
});
The widget is mounted. If it unmounts before all of this completes, some side effects may never run. Business consequences like token storage and navigation shouldn’t depend on whether a widget is still alive. This is fragile architecture.
Fat notifiers:
// Notifier doing too much
Future<void> login(LoginRequest request) async {
state = const AsyncLoading();
try {
final user = await _loginUseCase.execute(request);
await _secureStorage.write(key: 'token', value: user.token);
await _userCache.save(user);
_navigationService.navigateTo('/home');
_analytics.track('login_success');
state = AsyncData(user);
} catch (e, st) {
state = AsyncError(e, st);
}
}
The notifier is violating the Single Responsibility Principle. It’s performing the login, saving the token, caching the user, navigating, tracking analytics, and managing UI state. That’s six responsibilities in one class. It’s impossible to test cleanly and painful to maintain.
The Clean Rule
Before looking at the solution, establish this rule clearly:
The use case owns domain consequences. The notifier owns UI state. Widgets own nothing.
The use case performs the operation and publishes domain events. Handlers fire when those events are published and run completely independently of the widget lifecycle. The notifier receives the result from the use case and emits loading, success, or error state so the UI knows what to display. Widgets read that state and render accordingly.
That’s the full picture. And it means this architecture works correctly whether login is triggered from a widget, a biometric prompt, a deep link, or a background service. The use case always publishes. The handlers always fire. The notifier only deals with UI state.
Understanding AsyncNotifier
Before writing the notifier, let’s understand whatAsyncNotifieris and how it works.
AsyncNotifieris a Riverpod 2.0 class designed specifically for asynchronous state. It holds anAsyncValue<T\>, which is a sealed type that can be one of three things:
AsyncData<T\>means the operation succeeded and data is available.AsyncLoadingmeans an operation is in progress.AsyncErrormeans an operation failed.
When you extendAsyncNotifier<T\>, you implement abuildmethod that returns the initial state, and you write methods that mutatestateas async operations progress.
With code generation using@riverpod, you annotate your class and runflutter pub run build\_runner build. The generator creates the provider and all the boilerplate automatically. You focus entirely on the logic.
Here’s the full setup for code generation:
# pubspec.yaml
dependencies:
flutter_riverpod: ^2.5.1
riverpod_annotation: ^2.3.5
dev_dependencies:
riverpod_generator: ^2.4.0
build_runner: ^2.4.9
The Thin Notifier
// login_provider.dart
part 'login_provider.g.dart';
@riverpod
class LoginNotifier extends _$LoginNotifier {
@override
AsyncValue<UserDto?> build() {
return const AsyncData(null);
}
Future<void> login(LoginRequest request) async {
state = const AsyncLoading();
final result = await ref.read(loginUseCaseProvider).execute(request);
result.fold(
onSuccess: (user) => state = AsyncData(user),
onFailure: (error) => state = AsyncError(error, StackTrace.current),
);
}
}
Let’s go through this line by line.
part 'login\_provider\.g\.dart'tells Dart that the generated file is part of this library. The@riverpodannotation and\_$LoginNotifierbase class come from the generated file.
build\(\)is the initialisation method. It runs when the provider is first read. It returnsAsyncData\(null\), meaning the initial state is a successful state with no user yet. This is correct because no login has been attempted.
Insidelogin, the first thing we do is setstate = const AsyncLoading\(\). This immediately notifies any widget watching this provider that an operation is in progress. The UI can show a loading indicator.
We then call the use case andawaitits result. The use case returns aResult<UserDto, AppException\>, which is a type that holds either a success value or a failure value, never both. We callfoldon it to handle each case.
In theonSuccessbranch, we setstate = AsyncData\(user\). This tells the UI the operation succeeded and here is the user data to render.
In theonFailurebranch, we setstate = AsyncError\(error, StackTrace\.current\). This tells the UI something went wrong so it can display the appropriate error state.
That’s the entire notifier. It does exactly one thing: reflect the outcome of the use case as UI state.
Notice there’s no token saving here. No navigation, caching, or analytics. All of that is already handled. The moment the use case called\_eventBus\.publish\(UserLoggedIn\(\.\.\.\)\)insideexecute, every registered handler fired automatically. By the timeresultis returned to this notifier, all side effects are already done. The notifier just needs to update the UI.
This is the cleanest possible separation. The use case owns domain consequences. The notifier owns render state. Each has exactly one responsibility.
The Widget
class LoginPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final loginState = ref.watch(loginNotifierProvider);
return Scaffold(
body: loginState.when(
data: (_) => const LoginForm(),
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => ErrorView(message: error.toString()),
),
);
}
}
class LoginForm extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Column(
children: [
ElevatedButton(
onPressed: () {
ref.read(loginNotifierProvider.notifier).login(
LoginRequest(email: '[email protected]', password: 'secret'),
);
},
child: const Text('Login'),
),
],
);
}
}
ref\.watch\(loginNotifierProvider\)subscribes this widget to the notifier’s state. Every timestatechanges inside the notifier,buildis called again and the widget re-renders.
loginState\.whenis how you handle each case ofAsyncValue. When the state isAsyncData, it renders the login form. When it isAsyncLoading, it renders a loading indicator. When it isAsyncError, it renders the error view.
The widget knows nothing about tokens, navigation, or caching. It renders what it’s told to render by the state. That’s its entire job.
Wiring the Composition Root
All handler registrations happen once at app startup inside a Riverpod provider:
@riverpod
DomainEventBus eventBus(EventBusRef ref) {
final bus = DomainEventBus();
bus.register<UserLoggedIn>(
TokenHandler(ref.read(secureStorageProvider)),
);
bus.register<UserLoggedIn>(
UserCacheHandler(ref.read(userCacheProvider)),
);
bus.register<UserLoggedIn>(
NavigationHandler(ref.read(navigationServiceProvider)),
);
bus.register<UserLoggedIn>(
AnalyticsHandler(ref.read(analyticsServiceProvider)),
);
bus.register<LoginFailed>(
AnalyticsFailureHandler(ref.read(analyticsServiceProvider)),
);
return bus;
}
eventBusis a provider that creates theDomainEventBusand registers all handlers at the moment it is first read. Because Riverpod providers are lazy by default and cached after first creation, this runs once and the bus lives for the entire app session.
Every handler gets its dependencies injected viaref\.read. Nothing is hardcoded. Everything is swappable in tests.
TheLoginUseCasereceives this event bus as a dependency through its own provider:
@riverpod
LoginUseCase loginUseCase(LoginUseCaseRef ref) {
return LoginUseCase(
repository: ref.read(authRepositoryProvider),
eventBus: ref.read(eventBusProvider),
);
}
This is the only place that connects the use case to the event bus. The notifier receives only the use case. The widget receives only the notifier’s state. Each layer knows only about the layer directly below it and nothing else.
Adding a new side effect to login means creating a new handler class and adding onebus\.registerline in the composition root. The notifier, the use case logic, the widget, and every existing handler remain completely untouched.
Testing the Observer Architecture
One of the most significant advantages of this architecture is how clearly it separates test concerns. Each layer has its own focused test scope.
Testing the Use Case
void main() {
group('LoginUseCase', () {
late LoginUseCase useCase;
late MockAuthRepository mockRepository;
late MockDomainEventBus mockEventBus;
setUp(() {
mockRepository = MockAuthRepository();
mockEventBus = MockDomainEventBus();
useCase = LoginUseCase(
repository: mockRepository,
eventBus: mockEventBus,
);
});
test('publishes UserLoggedIn event on success', () async {
final user = UserDto(id: '1', token: 'token123');
when(() => mockRepository.login(any())).thenAnswer((_) async => user);
await useCase.execute(LoginRequest(email: '[email protected]', password: '123'));
verify(() => mockEventBus.publish(any<UserLoggedIn>())).called(1);
});
test('publishes LoginFailed event on error', () async {
when(() => mockRepository.login(any()))
.thenThrow(AppException.unauthorized(message: 'Invalid credentials'));
await useCase.execute(LoginRequest(email: '[email protected]', password: 'wrong'));
verify(() => mockEventBus.publish(any<LoginFailed>())).called(1);
});
});
}
The use case test mocks the repository and the event bus. It verifies that the correct event type was published for each outcome. It doesn’t test what any handler does. That’s not the use case’s responsibility, so it’s not the use case’s test.
Testing Each Handler
void main() {
group('TokenHandler', () {
late TokenHandler handler;
late MockSecureStorageService mockStorage;
setUp(() {
mockStorage = MockSecureStorageService();
handler = TokenHandler(mockStorage);
});
test('writes token to secure storage on UserLoggedIn', () {
final event = UserLoggedIn(
user: UserDto(id: '1', token: 'abc123'),
occurredAt: DateTime.now(),
eventId: 'event-1',
);
handler.handle(event);
verify(
() => mockStorage.write(key: 'auth_token', value: 'abc123'),
).called(1);
});
});
}
Each handler test is tiny. It creates the handler with a mocked dependency, fires the event, and verifies the exact side effect that handler is responsible for. No other handler is involved, no notifier is involved, and no widget is involved.
Testing the Notifier
void main() {
group('LoginNotifier', () {
test('transitions from loading to data on success', () async {
final mockUseCase = MockLoginUseCase();
final user = UserDto(id: '1', token: 'token123');
when(() => mockUseCase.execute(any()))
.thenAnswer((_) async => Result.success(user));
final container = ProviderContainer(overrides: [
loginUseCaseProvider.overrideWithValue(mockUseCase),
]);
final notifier = container.read(loginNotifierProvider.notifier);
await notifier.login(LoginRequest(email: '[email protected]', password: '123'));
expect(
container.read(loginNotifierProvider),
isA<AsyncData<UserDto?>>(),
);
});
test('transitions from loading to error on failure', () async {
final mockUseCase = MockLoginUseCase();
final error = AppException.unauthorized(message: 'Invalid credentials');
when(() => mockUseCase.execute(any()))
.thenAnswer((_) async => Result.failure(error));
final container = ProviderContainer(overrides: [
loginUseCaseProvider.overrideWithValue(mockUseCase),
]);
final notifier = container.read(loginNotifierProvider.notifier);
await notifier.login(LoginRequest(email: '[email protected]', password: 'wrong'));
expect(
container.read(loginNotifierProvider),
isA<AsyncError>(),
);
});
});
}
The notifier test only verifies state transitions. It doesn’t need to mock the event bus because the notifier no longer touches the event bus. That’s the use case’s job, and the use case has its own test that verifies events are published correctly. Each layer is tested in complete isolation with no overlap.
When to Use the Observer Pattern
Use Observer when:
- One event needs to trigger multiple independent reactions
- You want to add or remove reactions without modifying the event source
- Side effects need to be decoupled from business logic
- Each reaction should be independently testable
- Multiple parts of the system need to react to the same state change
- You are building a feature that will grow in number of side effects over time
When Not to Use It
Avoid Observer when:
- You have only one consumer and no realistic expectation of more
- The relationship between producer and consumer is simple and direct
- The pattern adds structural overhead without meaningful benefit
- Streams, ChangeNotifier, or Riverpod’s built-in reactivity already solve the problem naturally
- Strict ordering of side effects is critical and fan-out makes that hard to guarantee
Conclusion
The Observer Design Pattern is one of the most important tools in a software engineer’s arsenal. This isn’t because it’s clever, but because it solves a problem every growing application faces: how do you let one event trigger many reactions without turning your codebase into a tightly coupled mess?
You started by understanding the pattern at its core. A Subject holds a list of Observers and notifies them when events occur. You saw it built step by step in Dart, with snapshot iteration to prevent concurrent modification errors, per-observer try/catch to prevent failure cascades, and dependency inversion to keep everything testable.
You discovered that the Observer pattern is already embedded in Flutter’s Streams, ChangeNotifier, and BLoC. Understanding its foundations means you understand why those tools work the way they do.
You then took the pattern into Event-Driven Architecture, where events become immutable domain facts and the system is composed of producers and consumers with no direct coupling between them.
You applied it inside Domain-Driven Design, giving events a proper home in a pure Dart domain layer that is framework-independent, fully portable, and fully testable.
And you saw how it integrates with Riverpod through a hybrid architecture with a clear and enforced rule: handlers own side effects, the notifier owns UI state, and widgets own nothing.
The result is a codebase that scales gracefully. When a new side effect needs to be added, you create one handler and register it in one place. Nothing else changes. That’s the promise of the Observer pattern. And as you’ve seen throughout this handbook, it’s a promise it keeps.
Happy Coding!
Learn to code for free. freeCodeCamp’s open source curriculum has helped more than 40,000 people get jobs as developers.Get started
Similar Articles
@freeCodeCamp: Modern distributed systems need fast, reliable ways for services to communicate. In this handbook, @devseyi explains ho…
A comprehensive handbook explaining RPC, Protocol Buffers, and gRPC for building modern distributed systems, including a hands-on implementation with Dart and Flutter.
@freeCodeCamp: A Flutter app can be hard to ship safely when builds, signing, environment configs, and store releases are still manual…
This tutorial teaches how to build a production-ready Flutter CI/CD pipeline using GitHub Actions, including quality gates, Firebase and TestFlight distribution, and Sentry symbol uploads.
@jholtdigital: A friend encouraged me recently if I want to know how well something works for my use case, I should try it for a month…
A user shares experience using FactoryAI to convert a design system from HTML/CSS to Flutter widgets with E2E testing. The tool employs an orchestrator, workers, and validators using multiple AI models to plan and execute long-horizon tasks over 79 hours, spawning over 229 agents.
@freeCodeCamp: Distributed systems can break when a database write succeeds but the event publish fails. In this tutorial, @pliutau te…
This tutorial teaches how to implement the Outbox Pattern in Go and PostgreSQL to ensure reliable event publishing in distributed systems, including building a relay service and handling at-least-once delivery.
@freeCodeCamp: Large React apps get harder to scale when every feature has to live inside the core codebase. In this tutorial, Jessica…
This tutorial explains how to design a type-safe, lazy-loaded, and secure plugin architecture in React using TypeScript, covering plugin lifecycles, bundling, and runtime loading.