<?xml version="1.0" encoding="UTF-8"?>
<rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/"
    xmlns:atom="http://www.w3.org/2005/Atom" xmlns:media="http://search.yahoo.com/mrss/" version="2.0">
    <channel>
        
        <title>
            <![CDATA[ Riverpod - freeCodeCamp.org ]]>
        </title>
        <description>
            <![CDATA[ Browse thousands of programming tutorials written by experts. Learn Web Development, Data Science, DevOps, Security, and get developer career advice. ]]>
        </description>
        <link>https://www.freecodecamp.org/news/</link>
        <image>
            <url>https://cdn.freecodecamp.org/universal/favicons/favicon.png</url>
            <title>
                <![CDATA[ Riverpod - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Tue, 25 Aug 2026 22:03:39 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/tag/riverpod/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ Flutter Frontend Systems Design: How to Think Like a Senior Engineer in the AI Age ]]>
                </title>
                <description>
                    <![CDATA[ Systems design has always been treated as a backend problem. Ask a group of Flutter engineers what systems design means, and most will describe server architecture: load balancers, databases, and micr ]]>
                </description>
                <link>https://www.freecodecamp.org/news/flutter-frontend-systems-design-how-to-think-like-a-senior-engineer-in-the-ai-age/</link>
                <guid isPermaLink="false">6a79dcd1e93f9db759fd99d6</guid>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ System Design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mobile app development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software architecture ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Riverpod ]]>
                    </category>
                
                    <category>
                        <![CDATA[ interview-prep ]]>
                    </category>
                
                    <category>
                        <![CDATA[ AI ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Jesutoni Aderibigbe ]]>
                </dc:creator>
                <pubDate>Mon, 10 Aug 2026 14:14:41 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/682cb489-c8fd-4530-9226-357edb4e8c19.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Systems design has always been treated as a backend problem.</p>
<p>Ask a group of Flutter engineers what systems design means, and most will describe server architecture: load balancers, databases, and microservices.</p>
<p>Ask them to design a distributed cache or sketch out a message queue, and they'll hesitate. Ask them to design the Flutter client for a social feed, and they'll open a new file and start writing widgets.</p>
<p>That's the gap. And it's closing fast.</p>
<p>As Flutter applications grow more complex with real-time features, offline support, multiple platform targets, and AI-generated code that still needs to be maintainable, the architectural decisions you make before writing a single widget become just as important as your backend architecture.</p>
<p>Senior Flutter interviews at product companies increasingly test this skill. The engineers who can clearly explain <em>why</em> they chose a particular architecture, the trade-offs they considered, and the problems they were optimizing for are the ones who get hired and promoted.</p>
<p>This article is structured in two halves. The first half explains what frontend systems design actually is and why it matters for Flutter engineers specifically in 2026. The second half works through a full mock interview answer for one of the most common scenario questions: designing the Flutter architecture for a social feed with infinite scroll, likes, comments, and real-time updates. We'll walk through the kind of answer that separates mid-level from senior in an interview room.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>This article assumes you're a working Flutter developer comfortable with state management (Riverpod, Bloc, or similar), REST APIs, and basic Dart. You don't need backend experience, but familiarity with concepts like caching, pagination, and WebSockets will help you follow the deeper sections.</p>
<p>No code setup is required. This is a thinking and architecture article, not a tutorial. Dart/Flutter snippets are used to ground abstract ideas in concrete implementation.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-1-what-is-frontend-systems-design">1. What is Frontend Systems Design?</a></p>
</li>
<li><p><a href="#heading-2-why-flutter-engineers-cant-ignore-it-anymore">2. Why Flutter Engineers Can't Ignore It Anymore</a></p>
</li>
<li><p><a href="#heading-3-the-interview-format-what-to-expect">3. The Interview Format: What to Expect</a></p>
</li>
<li><p><a href="#heading-4-how-to-structure-your-answer">4. How to Structure Your Answer</a></p>
</li>
<li><p><a href="#heading-5-mock-interview-design-a-social-feed">5. Mock Interview: Design a Social Feed</a></p>
</li>
<li><p><a href="#heading-6-other-questions-to-prepare-for">6. Other Questions to Prepare For</a></p>
</li>
<li><p><a href="#heading-7-key-takeaways">7. Key Takeaways</a></p>
</li>
</ul>
<h2 id="heading-1-what-is-frontend-systems-design">1. What is Frontend Systems Design?</h2>
<p>Systems design is the practice of making high-level decisions about how a software system is structured before implementation begins: how its components are divided, how they communicate, how it handles scale, failure, and change over time.</p>
<p>On the backend, this means deciding between microservices and a monolith, choosing a database, designing an API contract, and planning for horizontal scaling. The feedback loop is fast: a bad database schema causes slow queries within days, and a poorly designed API breaks clients immediately.</p>
<p>On the frontend, the consequences of bad design are slower and quieter. A 600-line screen widget still ships. A god-class repository with 40 methods still works. State leaks between sessions only surface after a frustrated user reports it.</p>
<p>Frontend systems design asks the same category of questions, applied to the client layer:</p>
<ul>
<li><p>How do you divide a large app into independently-buildable features?</p>
</li>
<li><p>Where does business logic live, and what enforces that boundary?</p>
</li>
<li><p>How does data flow from the network to the screen and back?</p>
</li>
<li><p>What happens when the network fails, the API changes shape, or the user logs out mid-session?</p>
</li>
<li><p>How do you design components that can be tested in isolation?</p>
</li>
<li><p>How do you structure the app so a team of engineers can work on it without stepping on each other?</p>
</li>
</ul>
<p>These aren't widget questions. They're architecture questions. And they have answers: principled ones, with real tradeoffs.</p>
<h2 id="heading-2-why-flutter-engineers-cant-ignore-it-anymore">2. Why Flutter Engineers Can't Ignore It Anymore</h2>
<p>Three forces are pushing systems design into the Flutter conversation in a way that simply didn't exist three years ago.</p>
<h3 id="heading-flutter-apps-are-no-longer-just-uis">Flutter Apps Are No Longer Just UIs</h3>
<p>With Serverpod and Dart Frog on the server, Jaspr on the web, and Flutter on mobile and desktop, Dart is now a genuinely full-stack language. Engineers making architecture decisions that span mobile, web, and server in the same codebase need systems thinking, not just widget composition skills.</p>
<p>When your Freezed model is shared between the Flutter client and the Dart backend, the boundary between "frontend" and "backend" design dissolves. You're designing a system.</p>
<h3 id="heading-ai-agents-expose-bad-architecture-immediately">AI Agents Expose Bad Architecture Immediately</h3>
<p>This is the new pressure point. When Claude Code or any AI coding agent reads your project cold, it has no accumulated mental model to compensate for messiness. It reads files sequentially. It works within a limited context window. It makes decisions based on the patterns it sees.</p>
<p>A codebase with tangled dependencies, inconsistent naming, and business logic scattered across the widget tree produces unreliable AI output. This doesn't happen because the AI is wrong, but because the code doesn't communicate its own structure clearly enough to be navigated by something without human intuition.</p>
<p>Good systems design and AI-navigable architecture are almost identical. Feature-first structure, clear layer boundaries, consistent naming, small, focused files. These aren't just team hygiene practices anymore. They're what make AI-assisted development actually work at scale.</p>
<h3 id="heading-senior-flutter-interviews-now-test-it-explicitly">Senior Flutter Interviews Now Test it Explicitly</h3>
<p>As Flutter matures and product companies build larger apps with larger teams, the interview bar has risen. A mid-level Flutter interview might test widget lifecycle and state management fundamentals. A senior interview tests your ability to design a system you've never seen before, live, under pressure, while explaining your thinking out loud.</p>
<p>If you haven't thought about this before walking into that room, you'll be caught off guard.</p>
<h2 id="heading-3-the-interview-format-what-to-expect">3. The Interview Format: What to Expect</h2>
<p>Frontend systems design interviews at senior level typically run 45–60 minutes. You're given a vague scenario, like "design the <strong>Flutter client for a social feed"</strong>, and you're expected to drive the conversation.</p>
<p>The interviewer isn't looking for a single correct answer. They're watching how you think:</p>
<ul>
<li><p>Do you clarify requirements before jumping to solutions?</p>
</li>
<li><p>Do you identify the hard problems (real-time sync, optimistic UI, offline states) rather than the easy ones?</p>
</li>
<li><p>Do you make tradeoffs explicitly rather than just picking the thing you know best?</p>
</li>
<li><p>Can you go deep on any layer when pushed?</p>
</li>
</ul>
<p>The biggest mistake candidates make is opening Xcode or a code file immediately and starting to build. Systems design interviews are whiteboard conversations, not implementation sessions. Draw boxes. Name the layers. Talk through the data flow before writing a single method signature.</p>
<h2 id="heading-4-how-to-structure-your-answer">4. How to Structure Your Answer</h2>
<p>Use this framework for any frontend systems design question:</p>
<ol>
<li><p><strong>Clarify requirements (5 minutes)</strong> What platforms? How many users? Offline support? Real-time? Authentication? What's in scope for this conversation? Never assume.</p>
</li>
<li><p><strong>Define the data model (5–10 minutes)</strong> What are the core entities? What are their relationships? This anchors every architectural decision that follows.</p>
</li>
<li><p><strong>Design the layer architecture (10 minutes)</strong> How is the app divided? What are the layers? What enforces the boundaries between them?</p>
</li>
<li><p><strong>Solve the hard problems one by one (20–25 minutes)</strong> Pagination. Optimistic UI. Real-time sync. Offline. Performance. Go deep on each one, and name the tradeoffs.</p>
</li>
<li><p><strong>Address failure states (5 minutes)</strong> What breaks? What's the user experience when it does? Senior answers always include error handling.</p>
</li>
<li><p><strong>Summarise and invite questions (5 minutes)</strong> Recap the key decisions and the tradeoffs you made. Show you can hold the whole picture.</p>
</li>
</ol>
<h2 id="heading-5-mock-interview-design-a-social-feed">5. Mock Interview: Design a Social Feed</h2>
<blockquote>
<p><strong>Interviewer:</strong> Design the Flutter client architecture for a social feed. Users can scroll through posts, like and comment on them, and receive real-time updates when new posts arrive.</p>
</blockquote>
<p>This is the answer.</p>
<h3 id="heading-step-1-clarify-requirements">Step 1: Clarify Requirements</h3>
<p>Before touching architecture, ask the questions that constrain your decisions.</p>
<blockquote>
<p><em>"A few questions before I start. What platforms are we targeting? Mobile only, or web and desktop too? How many users are we designing for? Is this a startup MVP or an app at scale? Do we need offline support? How real-time does real-time need to be? Are we talking push notifications, or should the feed update while the user is looking at it? And what's the authentication model? Are users logged in, or is there a guest mode?"</em></p>
</blockquote>
<p>For this walkthrough, assume:</p>
<ul>
<li><p>Mobile (iOS + Android), with web on the roadmap</p>
</li>
<li><p>Tens of thousands of MAU. Not Twitter scale, but meaningful.</p>
</li>
<li><p>Offline: show cached content, queue interactions</p>
</li>
<li><p>Real-time: live feed updates while the screen is open (WebSocket)</p>
</li>
<li><p>Auth: logged-in users only</p>
</li>
</ul>
<p>These answers change every architectural decision that follows. Offline support means a local cache layer. Live updates while the screen is open means WebSockets, not polling. Web on the roadmap means avoiding anything mobile-only in the business logic layer.</p>
<h3 id="heading-step-2-define-the-data-model">Step 2: Define the Data Model</h3>
<p>Start with the entities and their relationships. Draw these before writing any code.</p>
<pre><code class="language-dart">// Core entities

@freezed
class Post with _$Post {
  const factory Post({
    required String id,
    required String authorId,
    required String authorName,
    required String authorAvatarUrl,
    required String content,
    String? imageUrl,
    required int likeCount,
    required int commentCount,
    required bool isLikedByMe,      // derived from current user context
    required DateTime createdAt,
  }) = _Post;
}

@freezed
class Comment with _$Comment {
  const factory Comment({
    required String id,
    required String postId,
    required String authorId,
    required String authorName,
    required String content,
    required DateTime createdAt,
  }) = _Comment;
}

@freezed
class FeedPage with _$FeedPage {
  const factory FeedPage({
    required List&lt;Post&gt; posts,
    required String? nextCursor,    // null = end of feed
  }) = _FeedPage;
}
</code></pre>
<p>A few design decisions embedded in this model are worth calling out explicitly in an interview:</p>
<p><code>isLikedByMe</code> <strong>lives on the Post.</strong> You could derive this from a separate user-likes table, but embedding it in the post response is simpler and makes the UI stateless. The screen doesn't need to join two data sources to render a like button.</p>
<p><strong>Cursor-based pagination, not offset.</strong> <code>nextCursor</code> rather than <code>page: 2</code>. Offset pagination breaks when new posts are inserted at the top. Item 20 on page 2 becomes item 21, and you either show a duplicate or skip an item. Cursors are stable.</p>
<p><code>likeCount</code> <strong>and</strong> <code>commentCount</code> <strong>are integers, not arrays.</strong> You don't fetch all likers to render a post. You fetch the count and a flag. This is a deliberate API contract decision that prevents unbounded payload size.</p>
<h3 id="heading-step-3-design-the-layer-architecture">Step 3: Design the Layer Architecture</h3>
<p>A feed is a good test of layer discipline because data flows in multiple directions: down from the API, up from user interactions, and sideways from real-time events. A flat architecture collapses quickly.</p>
<p>Here's the structure:</p>
<pre><code class="language-plaintext">lib/
├── core/
│   ├── network/          # Dio client, interceptors, token refresh
│   ├── cache/            # Local storage abstraction (Hive or Isar)
│   ├── realtime/         # WebSocket connection manager
│   └── errors/           # Typed error classes
└── features/
    └── feed/
        ├── data/
        │   ├── models/   # Post, Comment, FeedPage (Freezed)
        │   ├── sources/
        │   │   ├── feed_remote_source.dart   # API calls
        │   │   └── feed_local_source.dart    # Cache reads/writes
        │   └── repositories/
        │       └── feed_repository.dart      # Coordinates remote + local
        └── presentation/
            ├── screens/
            │   └── feed_screen.dart
            ├── widgets/
            │   ├── post_card.dart
            │   ├── like_button.dart
            │   └── comment_sheet.dart
            └── providers/
                ├── feed_provider.dart        # Paginated post list
                ├── like_provider.dart        # Like/unlike actions
                └── realtime_provider.dart    # WebSocket events → state
</code></pre>
<p>A couple things worth noting here:</p>
<p>First, the repository is the only component that talks to both the remote source and the local source. Providers call the repository. The repository decides whether to hit the network or return cached data. Screens never know the data came from cache.</p>
<p>Second, the real-time layer is separate from the data fetching layer. It's a common mistake to wire WebSocket events directly into the same provider that manages pagination, and it becomes impossible to test or reason about. The <code>realtime_provider</code> receives events and patches the feed state and the <code>feed_provider</code> manages the paginated list. They coordinate through Riverpod's <code>ref</code>, not through direct dependency.</p>
<h3 id="heading-step-4-pagination-and-infinite-scroll">Step 4: Pagination and Infinite Scroll</h3>
<p>Infinite scroll is the first hard problem. The naïve implementation: a <code>ListView</code> that loads everything falls apart at a few hundred posts.</p>
<p>Here's a Riverpod <code>AsyncNotifier</code> that handles cursor-based pagination:</p>
<pre><code class="language-dart">@riverpod
class FeedNotifier extends _$FeedNotifier {
  static const _pageSize = 20;
  String? _nextCursor;
  bool _isFetchingMore = false;

  @override
  Future&lt;List&lt;Post&gt;&gt; build() async {
    // Load first page + seed from cache if available
    final cached = await ref.read(feedLocalSourceProvider).getCachedPosts();
    if (cached.isNotEmpty) {
      // Show cache immediately, refresh in background
      _refreshInBackground();
      return cached;
    }
    return _fetchPage(cursor: null);
  }

  Future&lt;void&gt; loadMore() async {
    if (_isFetchingMore || _nextCursor == null) return;
    _isFetchingMore = true;

    final currentPosts = state.valueOrNull ?? [];
    final page = await ref
        .read(feedRepositoryProvider)
        .getFeedPage(cursor: _nextCursor, limit: _pageSize);

    _nextCursor = page.nextCursor;
    state = AsyncData([...currentPosts, ...page.posts]);
    _isFetchingMore = false;
  }

  Future&lt;List&lt;Post&gt;&gt; _fetchPage({required String? cursor}) async {
    final page = await ref
        .read(feedRepositoryProvider)
        .getFeedPage(cursor: cursor, limit: _pageSize);
    _nextCursor = page.nextCursor;
    await ref.read(feedLocalSourceProvider).cachePosts(page.posts);
    return page.posts;
  }

  void _refreshInBackground() {
    Future.microtask(() async {
      final freshPosts = await _fetchPage(cursor: null);
      state = AsyncData(freshPosts);
    });
  }

  bool get hasMore =&gt; _nextCursor != null;
}
</code></pre>
<p>In the screen, trigger <code>loadMore()</code> before the user reaches the bottom, not at the last item, but a few items before it:</p>
<pre><code class="language-dart">NotificationListener&lt;ScrollNotification&gt;(
  onNotification: (notification) {
    if (notification.metrics.pixels &gt;
        notification.metrics.maxScrollExtent - 400) {
      ref.read(feedNotifierProvider.notifier).loadMore();
    }
    return false;
  },
  child: ListView.builder(
    itemCount: posts.length + (hasMore ? 1 : 0),
    itemBuilder: (context, index) {
      if (index == posts.length) return const FeedLoadingIndicator();
      return PostCard(post: posts[index]);
    },
  ),
)
</code></pre>
<p>The 400-pixel threshold means the next page starts loading before the user sees the end of the list. The experience feels seamless.</p>
<h3 id="heading-step-5-optimistic-ui-for-likes-and-comments">Step 5: Optimistic UI for Likes and Comments</h3>
<p>Optimistic UI is the practice of updating the local state immediately when a user takes an action, before the server confirms it, then rolling back if the server rejects it. It's what makes a like button feel instant rather than laggy.</p>
<p>The pattern has three steps: apply the optimistic update, fire the network request, and roll back on failure.</p>
<pre><code class="language-dart">@riverpod
class LikeNotifier extends _$LikeNotifier {
  @override
  void build() {}

  Future&lt;void&gt; toggleLike(String postId) async {
    final feedNotifier = ref.read(feedNotifierProvider.notifier);
    final currentPosts = ref.read(feedNotifierProvider).valueOrNull ?? [];

    // Find the post
    final postIndex = currentPosts.indexWhere((p) =&gt; p.id == postId);
    if (postIndex == -1) return;
    final post = currentPosts[postIndex];

    // Step 1: Apply optimistic update immediately
    final optimisticPost = post.copyWith(
      isLikedByMe: !post.isLikedByMe,
      likeCount: post.isLikedByMe ? post.likeCount - 1 : post.likeCount + 1,
    );
    feedNotifier.patchPost(postIndex, optimisticPost);

    // Step 2: Fire the network request
    try {
      await ref.read(feedRepositoryProvider).toggleLike(postId);
    } catch (e) {
      // Step 3: Roll back on failure
      feedNotifier.patchPost(postIndex, post);
      // Show a snackbar or error indicator
    }
  }
}
</code></pre>
<p>The <code>patchPost</code> method on <code>FeedNotifier</code> replaces a single post in the list without rebuilding the whole feed. This is an important performance detail when the list has hundreds of items.</p>
<p><strong>The tradeoff to name explicitly in an interview:</strong> optimistic UI can produce an inconsistent state if the server is the source of truth for like counts. Two users liking simultaneously might both see their local count increment from 41 to 42, but the real count is 43. For a social app, this is usually acceptable. You show the user their action was registered, and the next feed refresh corrects the count. For financial transactions, an optimistic UI is inappropriate. Know where to draw the line.</p>
<h3 id="heading-step-6-real-time-updates">Step 6: Real-Time Updates</h3>
<p>Real-time feed updates and new posts appearing while the user is looking at the screen require a persistent connection. WebSocket is the right tool here. Server-Sent Events work too, but WebSocket is bidirectional, which matters if you later want to push events (typing indicators, presence).</p>
<p>Design the WebSocket layer as a singleton service, not inside the feed feature:</p>
<pre><code class="language-dart">// core/realtime/realtime_service.dart

class RealtimeService {
  WebSocketChannel? _channel;
  final _controller = StreamController&lt;RealtimeEvent&gt;.broadcast();

  Stream&lt;RealtimeEvent&gt; get events =&gt; _controller.stream;

  Future&lt;void&gt; connect(String token) async {
    _channel = WebSocketChannel.connect(
      Uri.parse('wss://api.yourapp.com/ws?token=$token'),
    );

    _channel!.stream.listen(
      (data) {
        final event = RealtimeEvent.fromJson(jsonDecode(data as String));
        _controller.add(event);
      },
      onError: (_) =&gt; _scheduleReconnect(),
      onDone: () =&gt; _scheduleReconnect(),
    );
  }

  void _scheduleReconnect() {
    Future.delayed(const Duration(seconds: 3), connect);
  }

  void dispose() {
    _channel?.sink.close();
    _controller.close();
  }
}
</code></pre>
<p>Then in the feed layer, listen to the stream and patch state when new posts arrive:</p>
<pre><code class="language-dart">@riverpod
class RealtimeFeedNotifier extends _$RealtimeFeedNotifier {
  StreamSubscription? _subscription;

  @override
  void build() {
    _subscription = ref
        .read(realtimeServiceProvider)
        .events
        .where((e) =&gt; e.type == RealtimeEventType.newPost)
        .listen((event) {
      final newPost = Post.fromJson(event.payload);
      ref.read(feedNotifierProvider.notifier).prependPost(newPost);
    });

    ref.onDispose(() =&gt; _subscription?.cancel());
  }
}
</code></pre>
<p><strong>The UX decision worth raising in an interview:</strong> do you silently prepend new posts to the top of the feed, or do you show a "3 new posts, tap to refresh" banner?</p>
<p>Silent prepend is jarring: the user is reading post 5, and suddenly they're reading post 8. The banner pattern (used by Twitter/X and LinkedIn) is almost always the better choice. It signals freshness without disrupting reading position.</p>
<h3 id="heading-step-7-offline-and-error-states">Step 7: Offline and Error States</h3>
<p>An offline-capable feed has two distinct requirements: show something useful when there's no connection, and queue interactions (likes, comments) so they fire when connectivity returns.</p>
<p>For showing cached content, the repository pattern handles this cleanly:</p>
<pre><code class="language-dart">// feed_repository.dart

Future&lt;List&lt;Post&gt;&gt; getFeed({String? cursor}) async {
  try {
    final page = await _remoteSource.getFeedPage(cursor: cursor);
    await _localSource.cachePosts(page.posts);
    return page.posts;
  } on DioException catch (e) {
    if (e.type == DioExceptionType.connectionError) {
      // Network unavailable — return cache
      final cached = await _localSource.getCachedPosts();
      if (cached.isNotEmpty) return cached;
    }
    rethrow;
  }
}
</code></pre>
<p>For queuing interactions offline, keep a simple pending actions queue in local storage:</p>
<pre><code class="language-dart">@freezed
class PendingAction with _$PendingAction {
  const factory PendingAction.like({
    required String postId,
    required bool isLike,
    required DateTime queuedAt,
  }) = PendingLike;

  const factory PendingAction.comment({
    required String postId,
    required String content,
    required DateTime queuedAt,
  }) = PendingComment;
}
</code></pre>
<p>When connectivity returns (detected via <code>connectivity_plus</code>), drain the queue and fire each action in order. If an action fails after retry, surface it to the user. Don't silently drop it.</p>
<h3 id="heading-step-8-performance-considerations">Step 8: Performance Considerations</h3>
<p>A feed is one of the most performance-sensitive screens in any app. There are a few non-negotiable practices:</p>
<p>First, use <code>ListView.builder</code>, never <code>ListView</code> with a <code>children</code> list. Builder renders only the items currently on screen. A <code>children</code> list renders all of them at once (which would be catastrophic for a feed of 200+ posts).</p>
<p>Second, keep <code>PostCard</code> build methods cheap. Every rebuild of a postcard is expensive at scale. Use <code>const</code> constructors everywhere possible. Avoid rebuilding the whole card when only the like count changes. Isolate the like button into its own Riverpod consumer.</p>
<pre><code class="language-dart">// Bad — whole PostCard rebuilds when like changes
class PostCard extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final post = ref.watch(feedNotifierProvider)
        .valueOrNull
        ?.firstWhere((p) =&gt; p.id == postId);
    // ...
  }
}

// Good — only LikeButton rebuilds
class LikeButton extends ConsumerWidget {
  final String postId;
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final post = ref.watch(
      feedNotifierProvider.select(
        (state) =&gt; state.valueOrNull?.firstWhere((p) =&gt; p.id == postId),
      ),
    );
    // Only rebuilds when this specific post's like state changes
  }
}
</code></pre>
<p>Third, cache network images aggressively. Use <code>cached_network_image</code> with a memory cache limit. On a feed with avatars and post images, uncached network images are the single biggest source of jank.</p>
<p>And lastly, dispose WebSocket connections on screen exit. Don't keep a real-time connection alive when the user navigates away. Riverpod's <code>ref.onDispose</code> makes this straightforward, but it's easy to miss.</p>
<h2 id="heading-6-other-questions-to-prepare-for">6. Other Questions to Prepare For</h2>
<p>The social feed covers most of the hard architectural territory. These additional questions round out your preparation:</p>
<p><strong>Architecture &amp; structure:</strong></p>
<ul>
<li><p>How would you structure a large Flutter app for a team of 10 engineers?</p>
</li>
<li><p>How do you handle shared state between two features that shouldn't know about each other?</p>
</li>
<li><p>Walk me through how you'd design the data layer for an offline-first app.</p>
</li>
</ul>
<p><strong>State management:</strong></p>
<ul>
<li><p>Compare Riverpod, Bloc, and Redux from an architecture standpoint (not just API differences).</p>
</li>
<li><p>How do you prevent the state from leaking between sessions after a user logs out?</p>
</li>
</ul>
<p><strong>Networking &amp; data:</strong></p>
<ul>
<li><p>How would you handle token refresh across concurrent requests?</p>
</li>
<li><p>Walk me through optimistic UI for a financial transaction. How is it different from liking a post?</p>
</li>
</ul>
<p><strong>Performance:</strong></p>
<ul>
<li><p>A screen has 10,000 items. How do you render it without jank?</p>
</li>
<li><p>How do you design an image-loading system for a feed with mixed media types?</p>
</li>
</ul>
<p><strong>Multi-platform:</strong></p>
<ul>
<li><p>How would you share models and business logic between a Flutter mobile app and a Dart backend?</p>
</li>
<li><p>What changes about your architecture when you add a web as a target?</p>
</li>
</ul>
<p>For each of these, use the same framework: clarify the constraints, define the data model, name the layers, solve the hard problems explicitly, and address failure states.</p>
<h2 id="heading-7-key-takeaways">7. Key Takeaways</h2>
<p>Systems design is not a backend discipline that Flutter engineers are exempt from. It's a way of thinking about software that becomes unavoidable as apps grow in complexity, teams grow in size, and AI agents become part of the development workflow.</p>
<p>The social feed scenario illustrates five principles that apply across every frontend systems design problem:</p>
<h3 id="heading-1-layer-boundaries-are-load-bearing">1. Layer Boundaries Are Load-bearing</h3>
<p>The repository pattern, the separation of real-time from data fetching, and the isolation of pending actions aren't academic choices. They're what makes the system testable, navigable, and maintainable when requirements change.</p>
<h3 id="heading-2-the-data-model-anchors-everything">2. The Data Model Anchors Everything</h3>
<p>Decisions you make in the model (like cursor-based pagination, <code>isLikedByMe</code> on the post, and integer counts instead of arrays) ripple through every layer. Get the model right before designing anything else.</p>
<h3 id="heading-3-optimistic-ui-is-a-ux-contract-not-just-a-pattern">3. Optimistic UI is a UX Contract, Not Just a Pattern</h3>
<p>When you apply an optimistic update, you're making a promise to the user. Know when that promise is appropriate (social interactions) and when it isn't (financial transactions).</p>
<h3 id="heading-4-real-time-is-an-architecture-concern-not-a-feature">4. Real-time is an Architecture Concern, Not a Feature</h3>
<p>A WebSocket connection is a persistent resource that needs to be managed, connected when needed, disconnected when not, and reconnected on failure. Design it as infrastructure, not as part of a single screen.</p>
<h3 id="heading-5-offline-is-a-first-class-state">5. Offline is a First-class State</h3>
<p>Not an edge case, not a "nice to have." In markets with unreliable connectivity, which includes most of the world's fastest-growing mobile markets, an app that shows nothing when the network drops is a broken app.</p>
<p>The engineers who understand these principles and can articulate them out loud under interview pressure are the ones who get hired to build the systems that millions of people use.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ The Observer Design Pattern Handbook: Event-Driven Architecture & Domain-Driven Design in Dart ]]>
                </title>
                <description>
                    <![CDATA[ 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 th ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-observer-design-pattern-handbook-event-driven-architecture-domain-driven-design-in-dart/</link>
                <guid isPermaLink="false">6a59593c2c971321745e7720</guid>
                
                    <category>
                        <![CDATA[ #Domain-Driven-Design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Observer Pattern ]]>
                    </category>
                
                    <category>
                        <![CDATA[ design patterns ]]>
                    </category>
                
                    <category>
                        <![CDATA[ behavioural patterns ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Software Engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Riverpod ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Clean Architecture ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Thu, 16 Jul 2026 22:20:44 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/0621293d-e82e-4f24-bb6e-40dec481c7cd.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every application, at some point, has to deal with a fundamental challenge: something happens, and several other things need to react to it.</p>
<p>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.</p>
<p>A payment is confirmed, and the inventory needs to update, the user needs a receipt, and the fulfillment system needs to kick off delivery.</p>
<p>A sensor reading changes, and three different UI panels need to reflect the new value simultaneously.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>By the end, you won't just understand the pattern. You'll know how to use it deliberately in production code.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-the-observer-design-pattern">What is the Observer Design Pattern?</a></p>
</li>
<li><p><a href="#heading-the-problem-it-solves">The Problem It Solves</a></p>
</li>
<li><p><a href="#heading-core-components">Core Components</a></p>
</li>
<li><p><a href="#heading-implementing-observer-in-dart">Implementing Observer in Dart</a></p>
</li>
<li><p><a href="#heading-a-real-world-example-the-login-flow">A Real-World Example: The Login Flow</a></p>
</li>
<li><p><a href="#heading-making-it-production-grade-with-a-generic-eventbus">Making It Production-Grade with a Generic EventBus</a></p>
</li>
<li><p><a href="#heading-observer-is-already-in-your-flutter-code">Observer Is Already in Your Flutter Code</a></p>
</li>
<li><p><a href="#heading-deep-dive-into-event-driven-architecture">Deep Dive Into Event-Driven Architecture</a></p>
</li>
<li><p><a href="#heading-application-in-domain-driven-design">Application in Domain-Driven Design</a></p>
</li>
<li><p><a href="#heading-the-riverpod-hybrid-clean-architecture-in-practice">The Riverpod Hybrid: Clean Architecture in Practice</a></p>
</li>
<li><p><a href="#heading-testing-the-observer-architecture">Testing the Observer Architecture</a></p>
</li>
<li><p><a href="#heading-when-to-use-the-observer-pattern">When to Use the Observer Pattern</a></p>
</li>
<li><p><a href="#heading-when-not-to-use-it">When Not to Use It</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-is-the-observer-design-pattern">What is the Observer Design Pattern?</h2>
<p>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.</p>
<p>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.</p>
<p>A subscriber can cancel at any time. A new subscriber can join at any time. The publisher's job never changes. It just publishes.</p>
<p>That's the Observer pattern in plain terms.</p>
<p>The publisher is called the <strong>Subject</strong>. The subscribers are called <strong>Observers</strong>. The newspaper is the <strong>event</strong>.</p>
<p>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.</p>
<h2 id="heading-the-problem-it-solves">The Problem It Solves</h2>
<p>Let's look at what happens without the Observer pattern.</p>
<p>Say you have a login feature. When login succeeds, you need to do four things:</p>
<ul>
<li><p>Save the authentication token to secure storage</p>
</li>
<li><p>Cache the user profile data</p>
</li>
<li><p>Navigate to the home screen</p>
</li>
<li><p>Fire an analytics event</p>
</li>
</ul>
<p>The straightforward approach puts all of this inside the login function:</p>
<pre><code class="language-dart">Future&lt;void&gt; 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});
}
</code></pre>
<p>This looks fine at first glance. But count how many reasons this single function has to change:</p>
<ul>
<li><p>The token storage strategy changes. You modify this function.</p>
</li>
<li><p>The navigation destination changes. You modify this function.</p>
</li>
<li><p>The analytics event name or payload changes. You modify this function.</p>
</li>
<li><p>The user caching logic changes. You modify this function.</p>
</li>
</ul>
<p>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.</p>
<p>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.</p>
<p>This is called tight coupling. The login logic is coupled to every single consequence of a successful login.</p>
<p>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.</p>
<h2 id="heading-core-components">Core Components</h2>
<p>The Observer pattern has four core building blocks. Understanding each one before writing code makes the implementation much easier to follow.</p>
<h3 id="heading-subject">Subject</h3>
<p>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.</p>
<h3 id="heading-observer">Observer</h3>
<p>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.</p>
<h3 id="heading-concrete-subject">Concrete Subject</h3>
<p>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.</p>
<h3 id="heading-concrete-observers">Concrete Observers</h3>
<p>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.</p>
<p>Here's how they relate to each other:</p>
<pre><code class="language-cpp">Subject (LoginService)
    |
    |-- subscribe(observer)    &lt;- observer registers itself
    |-- unsubscribe(observer)  &lt;- observer removes itself
    |-- notifySuccess(data)    &lt;- fires when login succeeds
    |-- notifyFailure(error)   &lt;- fires when login fails
         |
         |-----&gt; TokenObserver.onLoginSuccess()
         |-----&gt; UserObserver.onLoginSuccess()
         |-----&gt; NavigationObserver.onLoginSuccess()
         |-----&gt; AnalyticsObserver.onLoginSuccess()
</code></pre>
<p>The Subject notifies all of them. They each handle their own job independently.</p>
<h2 id="heading-implementing-observer-in-dart">Implementing Observer in Dart</h2>
<p>Let's build the pattern step by step.</p>
<h3 id="heading-step-1-define-the-observer-interface">Step 1: Define the Observer Interface</h3>
<pre><code class="language-dart">abstract class LoginObserver {
  void onLoginSuccess(UserDto user);
  void onLoginFailed(AppException error);
}
</code></pre>
<p>This is the contract that every observer must sign. Any class that wants to react to login events must implement both of these methods.</p>
<p><code>onLoginSuccess</code> is called when the login succeeds and receives the user data. <code>onLoginFailed</code> is called when the login fails and receives the error.</p>
<h3 id="heading-step-2-define-the-subject-interface">Step 2: Define the Subject Interface</h3>
<pre><code class="language-dart">abstract class LoginSubject {
  void subscribe(LoginObserver observer);
  void unsubscribe(LoginObserver observer);
  void notifySuccess(UserDto user);
  void notifyFailure(AppException error);
}
</code></pre>
<p><code>subscribe</code> lets an observer join the notification list. <code>unsubscribe</code> lets an observer leave the notification list. <code>notifySuccess</code> broadcasts a success event with the user data to all registered observers. <code>notifyFailure</code> broadcasts a failure event with the error to all registered observers.</p>
<p>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.</p>
<h3 id="heading-step-3-implement-the-concrete-subject">Step 3: Implement the Concrete Subject</h3>
<pre><code class="language-dart">class LoginService implements LoginSubject {
  final List&lt;LoginObserver&gt; _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');
      }
    }
  }
}
</code></pre>
<p>There are two important decisions in this implementation that are easy to miss.</p>
<h4 id="heading-1-snapshot-iteration-with-listof">1. Snapshot iteration with <code>List.of()</code></h4>
<p>Instead of iterating directly over <code>_observers</code>, we iterate over <code>List.of(_observers)</code>, which creates a copy of the list before the loop runs.</p>
<p>Why does this matter? Imagine a <code>NavigationObserver</code> that, after navigating to the home screen, unsubscribes itself because it no longer needs to listen. If it calls <code>unsubscribe</code> while the <code>notifySuccess</code> loop is still running over the same list, Dart throws a <code>ConcurrentModificationError</code>. The list is being modified while it's being read.</p>
<p><code>List.of()</code> prevents this entirely. The loop runs over the snapshot. The original list can be modified freely during iteration without any errors.</p>
<h4 id="heading-2-per-observer-trycatch">2. Per-observer try/catch</h4>
<p>Each observer call is wrapped in its own try/catch block. This is a deliberate choice. If <code>TokenObserver</code> throws an exception while writing to secure storage, you don't want <code>NavigationObserver</code> and <code>AnalyticsObserver</code> to silently never fire. Each observer gets its chance to run regardless of what the others do.</p>
<p>Without this, one failing observer would stop the entire notification chain. That's a hidden bug that's extremely difficult to trace in production.</p>
<h2 id="heading-a-real-world-example-the-login-flow">A Real-World Example: The Login Flow</h2>
<p>Now let's build the full login flow using this foundation.</p>
<h3 id="heading-the-login-logic">The Login Logic</h3>
<pre><code class="language-cpp">class LoginLogic {
  final LoginSubject _subject;
  final AuthRepository _repository;

  LoginLogic({
    required LoginSubject subject,
    required AuthRepository repository,
  })  : _subject = subject,
        _repository = repository;

  Future&lt;void&gt; 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()));
    }
  }
}
</code></pre>
<p>Let's walk through this carefully.</p>
<p><code>LoginLogic</code> takes two dependencies through its constructor: a <code>LoginSubject</code> and an <code>AuthRepository</code>. Notice it takes <code>LoginSubject</code>, the abstraction, not <code>LoginService</code>, the concrete class. This means you can swap the implementation in tests or in different environments without changing <code>LoginLogic</code> at all.</p>
<p>Inside <code>callLogin</code>, the logic is straightforward. It calls the repository to perform the actual login. If that succeeds, it calls <code>notifySuccess</code> on the subject with the returned user. If it throws an <code>AppException</code>, it calls <code>notifyFailure</code> with that error. If it throws anything unexpected, it wraps it in an <code>AppException.unknown</code> and notifies failure.</p>
<p>Notice what <code>LoginLogic</code> does 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.</p>
<p>Its entire responsibility is: perform the login, announce the result.</p>
<h3 id="heading-the-concrete-observers">The Concrete Observers</h3>
<pre><code class="language-cpp">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');
  }
}
</code></pre>
<p><code>TokenObserver</code> has 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.</p>
<pre><code class="language-cpp">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();
  }
}
</code></pre>
<p><code>UserObserver</code> has 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.</p>
<pre><code class="language-cpp">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);
  }
}
</code></pre>
<p><code>NavigationObserver</code> has one job: handle navigation after a login attempt. It uses an injected <code>NavigationService</code> abstraction rather than a <code>BuildContext</code>. This is intentional. An observer that depends on <code>BuildContext</code> is tied to the widget lifecycle. Using an abstraction keeps this observer completely independent of the UI layer.</p>
<pre><code class="language-cpp">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});
  }
}
</code></pre>
<p><code>AnalyticsObserver</code> has one job: fire the right analytics event for each outcome. It has no knowledge of storage, navigation, or caching.</p>
<p>Each observer has exactly one responsibility. Each one has exactly one reason to change. When the analytics payload needs to change, you touch only <code>AnalyticsObserver</code>. When navigation logic changes, you touch only <code>NavigationObserver</code>. Nothing else is affected.</p>
<h3 id="heading-wiring-it-together">Wiring It Together</h3>
<pre><code class="language-cpp">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,
  );
}
</code></pre>
<p>This is the composition step. All observers are created with their dependencies and registered onto the service. The cascade operator <code>..</code> calls <code>subscribe</code> multiple times on the same <code>service</code> object, which keeps the setup readable.</p>
<p><code>LoginLogic</code> receives the <code>service</code> as its <code>LoginSubject</code>. From this point forward, every time <code>callLogin</code> is called and an outcome occurs, all four observers are notified automatically.</p>
<p>Adding a fifth observer, say a <code>PushNotificationObserver</code>, means creating the class and adding one line here: <code>..subscribe(PushNotificationObserver(pushService))</code>. Nothing else in the entire codebase changes.</p>
<h2 id="heading-making-it-production-grade-with-a-generic-eventbus">Making It Production-Grade with a Generic EventBus</h2>
<p>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.</p>
<p>Rewriting the Subject and Observer interfaces per feature is repetitive and unnecessary. The better approach is a generic <code>EventBus</code> that any feature can use.</p>
<pre><code class="language-cpp">abstract class DomainObserver&lt;T&gt; {
  void onSuccess(T data);
  void onFailure(AppException error);
}
</code></pre>
<p><code>DomainObserver&lt;T&gt;</code> is a generic observer. The type parameter <code>T</code> represents the data type the observer expects on success. A login observer would be <code>DomainObserver&lt;UserDto&gt;</code>. A payment observer would be <code>DomainObserver&lt;PaymentDto&gt;</code>. The interface is the same. The data type changes per feature.</p>
<pre><code class="language-cpp">class EventBus&lt;T&gt; {
  final List&lt;DomainObserver&lt;T&gt;&gt; _observers = [];

  void subscribe(DomainObserver&lt;T&gt; observer) {
    _observers.add(observer);
  }

  void unsubscribe(DomainObserver&lt;T&gt; 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');
      }
    }
  }
}
</code></pre>
<p><code>EventBus&lt;T&gt;</code> 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.</p>
<p>Now every feature gets the same infrastructure without duplicating a single line of the pattern:</p>
<pre><code class="language-cpp">final loginBus = EventBus&lt;UserDto&gt;();
final paymentBus = EventBus&lt;PaymentDto&gt;();
final orderBus = EventBus&lt;OrderDto&gt;();
</code></pre>
<p>Each bus is typed to its domain concept. Observers registered on <code>loginBus</code> will never accidentally receive payment events. The type system enforces correctness.</p>
<h2 id="heading-observer-is-already-in-your-flutter-code">Observer Is Already in Your Flutter Code</h2>
<p>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.</p>
<p><strong>Streams and StreamController:</strong></p>
<pre><code class="language-cpp">final controller = StreamController&lt;String&gt;();

controller.stream.listen((event) {
  print('Observed: $event');
});

controller.sink.add('Login succeeded');
</code></pre>
<p><code>StreamController</code> is a Subject. <code>stream.listen</code> is <code>subscribe</code>. <code>sink.add</code> is <code>notifyObservers</code>. Every stream subscription is an Observer. The pattern is identical. Flutter just gave it different names.</p>
<p><strong>ChangeNotifier:</strong></p>
<pre><code class="language-cpp">class CounterModel extends ChangeNotifier {
  int _count = 0;

  void increment() {
    _count++;
    notifyListeners();
  }
}
</code></pre>
<p><code>notifyListeners()</code> iterates over every registered listener and calls them. Those listeners are Observers. <code>addListener</code> is <code>subscribe</code>. <code>removeListener</code> is <code>unsubscribe</code>. <code>ChangeNotifier</code> is a concrete Subject.</p>
<p><strong>BLoC:</strong></p>
<p>When a BLoC emits a new state, every widget that wrapped itself in a <code>BlocBuilder</code> or <code>BlocListener</code> reacts. The BLoC is the Subject. The builders and listeners are Observers. The state emission is the notification.</p>
<p>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.</p>
<h2 id="heading-deep-dive-into-event-driven-architecture">Deep Dive Into Event-Driven Architecture</h2>
<p>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.</p>
<h3 id="heading-what-is-event-driven-architecture">What is Event-Driven Architecture?</h3>
<p>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.</p>
<p>In a traditional request-driven flow, this is what happens:</p>
<pre><code class="language-cpp">Component A calls Component B directly
Component B does its work and returns a result
Component A waits for that result and then continues
</code></pre>
<p>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.</p>
<p>But then Component A grows. Component A becomes responsible for orchestrating consequences it should know nothing about.</p>
<p>In an event-driven flow, this is what happens instead:</p>
<pre><code class="language-cpp">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
</code></pre>
<p>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.</p>
<h3 id="heading-events-are-facts-not-commands">Events Are Facts, Not Commands</h3>
<p>This distinction is one of the most important concepts in Event-Driven Architecture.</p>
<p>A command says: "do this." It's an instruction that can be rejected. It expects a response.</p>
<p>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.</p>
<p><code>SaveUserToken</code> is a command. <code>UserLoggedIn</code> is an event.</p>
<p>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.</p>
<h3 id="heading-modelling-domain-events-in-dart">Modelling Domain Events in Dart</h3>
<p>Events should be immutable value objects. They're facts. Facts don't change after they happen.</p>
<pre><code class="language-cpp">abstract class DomainEvent {
  final DateTime occurredAt;
  final String eventId;

  const DomainEvent({
    required this.occurredAt,
    required this.eventId,
  });
}
</code></pre>
<p><code>DomainEvent</code> is the base class for all events in the system. Every event has a timestamp (<code>occurredAt</code>) recording when it happened, and a unique identifier (<code>eventId</code>) for traceability.</p>
<pre><code class="language-cpp">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,
  });
}
</code></pre>
<p><code>UserLoggedIn</code> carries the user data. <code>LoginFailed</code> carries the error. Both are immutable. Both have timestamps and identifiers. Both are concrete facts about something that happened in the domain.</p>
<h3 id="heading-a-type-safe-domaineventbus">A Type-Safe DomainEventBus</h3>
<p>Now we can build an event bus that's typed to domain events specifically:</p>
<pre><code class="language-cpp">abstract class EventHandler&lt;T extends DomainEvent&gt; {
  void handle(T event);
}
</code></pre>
<p><code>EventHandler&lt;T&gt;</code> 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.</p>
<pre><code class="language-cpp">class DomainEventBus {
  final _handlers = &lt;Type, List&lt;EventHandler&gt;&gt;{};

  void register&lt;T extends DomainEvent&gt;(EventHandler&lt;T&gt; handler) {
    _handlers.putIfAbsent(T, () =&gt; []).add(handler);
  }

  void publish&lt;T extends DomainEvent&gt;(T event) {
    final handlers = List.of(_handlers[T] ?? []);
    for (final handler in handlers) {
      try {
        (handler as EventHandler&lt;T&gt;).handle(event);
      } catch (e) {
        debugPrint('[DomainEventBus] Handler error for ${T}: $e');
      }
    }
  }
}
</code></pre>
<p>Let's go through <code>DomainEventBus</code> carefully.</p>
<p><code>_handlers</code> is a map where the key is a <code>Type</code> (the event class itself, like <code>UserLoggedIn</code>) and the value is a list of all handlers registered for that event type.</p>
<p><code>register&lt;T&gt;</code> takes a handler and adds it to the list for type <code>T</code>. <code>putIfAbsent</code> ensures the list is created if this is the first handler for that event type.</p>
<p><code>publish&lt;T&gt;</code> looks up all handlers registered for the type of event being published and calls each one's <code>handle</code> method. The snapshot with <code>List.of()</code> and the per-handler try/catch are both present for the same reasons we established earlier.</p>
<p>Here's how you register handlers and publish events:</p>
<pre><code class="language-dart">// Registration happens once at startup
eventBus.register&lt;UserLoggedIn&gt;(TokenHandler(secureStorage));
eventBus.register&lt;UserLoggedIn&gt;(UserCacheHandler(userCache));
eventBus.register&lt;UserLoggedIn&gt;(NavigationHandler(navigationService));
eventBus.register&lt;UserLoggedIn&gt;(AnalyticsHandler(analyticsService));

eventBus.register&lt;PaymentConfirmed&gt;(ReceiptHandler(receiptService));
eventBus.register&lt;PaymentConfirmed&gt;(InventoryHandler(inventoryService));

// Publishing happens at the use case level
eventBus.publish(UserLoggedIn(
  user: user,
  occurredAt: DateTime.now(),
  eventId: const Uuid().v4(),
));
</code></pre>
<p>When <code>UserLoggedIn</code> is published, only its registered handlers fire. Payment handlers aren't touched. Every handler for <code>UserLoggedIn</code> runs independently with full error isolation.</p>
<h2 id="heading-application-in-domain-driven-design">Application in Domain-Driven Design</h2>
<p>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.</p>
<h3 id="heading-key-ddd-concepts-you-need-to-know">Key DDD Concepts You Need to Know</h3>
<p><strong>Domain Events</strong> are 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.</p>
<p><code>UserLoggedIn</code> is a domain event. <code>LoginResponseDto</code> is 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.</p>
<p><strong>Aggregates</strong> are 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.</p>
<p><strong>Use Cases</strong> are 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.</p>
<h3 id="heading-where-everything-lives-in-clean-architecture">Where Everything Lives in Clean Architecture</h3>
<pre><code class="language-plaintext">lib/
  core/
    events/
      domain_event.dart           &lt;- Base DomainEvent class
      domain_event_bus.dart       &lt;- The DomainEventBus
      event_handler.dart          &lt;- Base EventHandler interface

  features/
    auth/
      domain/
        events/
          user_logged_in.dart     &lt;- Domain event (pure Dart, no Flutter)
          login_failed.dart       &lt;- Domain event
        handlers/
          token_handler.dart      &lt;- Handles token storage
          user_cache_handler.dart &lt;- Handles user caching
          analytics_handler.dart  &lt;- Handles analytics
        entities/
          user.dart
        repositories/
          auth_repository.dart    &lt;- Abstract interface only
        usecases/
          login_usecase.dart      &lt;- Orchestrates, publishes events

      data/
        repositories/
          auth_repository_impl.dart
        datasources/
          auth_remote_datasource.dart

      presentation/
        providers/
          login_provider.dart     &lt;- Riverpod notifier (thin)
        pages/
          login_page.dart
</code></pre>
<p>The critical rule: the domain layer is pure Dart. No Flutter imports. No Riverpod imports. No HTTP imports. The <code>DomainEventBus</code>, domain events, handlers, and use cases all live in the domain layer and have zero framework dependencies.</p>
<p>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.</p>
<h3 id="heading-the-login-use-case-in-ddd">The Login Use Case in DDD</h3>
<pre><code class="language-cpp">class LoginUseCase {
  final AuthRepository _repository;
  final DomainEventBus _eventBus;

  LoginUseCase({
    required AuthRepository repository,
    required DomainEventBus eventBus,
  })  : _repository = repository,
        _eventBus = eventBus;

  Future&lt;Result&lt;UserDto, AppException&gt;&gt; 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);
    }
  }
}
</code></pre>
<p>Let's walk through this step by step.</p>
<p><code>LoginUseCase</code> receives two dependencies: an <code>AuthRepository</code> abstraction and a <code>DomainEventBus</code>. Neither is a concrete class. Both can be swapped in tests.</p>
<p>Inside <code>execute</code>, it calls the repository to perform the login. If the login succeeds, it publishes a <code>UserLoggedIn</code> event to the bus, which immediately notifies all registered handlers. Then it returns a <code>Result.success</code> wrapping the user data.</p>
<p>If an <code>AppException</code> is caught, it publishes a <code>LoginFailed</code> event to the bus, which notifies all failure handlers. Then it returns a <code>Result.failure</code> wrapping the error.</p>
<p>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.</p>
<p>The <code>Result</code> type 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.</p>
<h2 id="heading-the-riverpod-hybrid-clean-architecture-in-practice">The Riverpod Hybrid: Clean Architecture in Practice</h2>
<p>This is where everything comes together in a real Flutter application.</p>
<h3 id="heading-the-problem-we-are-solving">The Problem We Are Solving</h3>
<p>There are two common pain points in Flutter apps that use Riverpod:</p>
<p>Fat ref.listen in widgets:</p>
<pre><code class="language-cpp">// This is messy
ref.listen&lt;AsyncValue&lt;UserDto?&gt;&gt;(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');
    }
  });
});
</code></pre>
<p>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.</p>
<p>Fat notifiers:</p>
<pre><code class="language-dart">// Notifier doing too much
Future&lt;void&gt; 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);
  }
}
</code></pre>
<p>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.</p>
<h3 id="heading-the-clean-rule">The Clean Rule</h3>
<p>Before looking at the solution, establish this rule clearly:</p>
<p><strong>The use case owns domain consequences. The notifier owns UI state. Widgets own nothing.</strong></p>
<p>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.</p>
<p>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.</p>
<h3 id="heading-understanding-asyncnotifier">Understanding AsyncNotifier</h3>
<p>Before writing the notifier, let's understand what <code>AsyncNotifier</code> is and how it works.</p>
<p><code>AsyncNotifier</code> is a Riverpod 2.0 class designed specifically for asynchronous state. It holds an <code>AsyncValue&lt;T&gt;</code>, which is a sealed type that can be one of three things:</p>
<p><code>AsyncData&lt;T&gt;</code> means the operation succeeded and data is available. <code>AsyncLoading</code> means an operation is in progress. <code>AsyncError</code> means an operation failed.</p>
<p>When you extend <code>AsyncNotifier&lt;T&gt;</code>, you implement a <code>build</code> method that returns the initial state, and you write methods that mutate <code>state</code> as async operations progress.</p>
<p>With code generation using <code>@riverpod</code>, you annotate your class and run <code>flutter pub run build_runner build</code>. The generator creates the provider and all the boilerplate automatically. You focus entirely on the logic.</p>
<p>Here's the full setup for code generation:</p>
<pre><code class="language-yaml"># 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
</code></pre>
<h3 id="heading-the-thin-notifier">The Thin Notifier</h3>
<pre><code class="language-cpp">// login_provider.dart
part 'login_provider.g.dart';

@riverpod
class LoginNotifier extends _$LoginNotifier {

  @override
  AsyncValue&lt;UserDto?&gt; build() {
    return const AsyncData(null);
  }

  Future&lt;void&gt; login(LoginRequest request) async {
    state = const AsyncLoading();

    final result = await ref.read(loginUseCaseProvider).execute(request);

    result.fold(
      onSuccess: (user) =&gt; state = AsyncData(user),
      onFailure: (error) =&gt; state = AsyncError(error, StackTrace.current),
    );
  }
}
</code></pre>
<p>Let's go through this line by line.</p>
<p><code>part 'login_provider.g.dart'</code> tells Dart that the generated file is part of this library. The <code>@riverpod</code> annotation and <code>_$LoginNotifier</code> base class come from the generated file.</p>
<p><code>build()</code> is the initialisation method. It runs when the provider is first read. It returns <code>AsyncData(null)</code>, meaning the initial state is a successful state with no user yet. This is correct because no login has been attempted.</p>
<p>Inside <code>login</code>, the first thing we do is set <code>state = const AsyncLoading()</code>. This immediately notifies any widget watching this provider that an operation is in progress. The UI can show a loading indicator.</p>
<p>We then call the use case and <code>await</code> its result. The use case returns a <code>Result&lt;UserDto, AppException&gt;</code>, which is a type that holds either a success value or a failure value, never both. We call <code>fold</code> on it to handle each case.</p>
<p>In the <code>onSuccess</code> branch, we set <code>state = AsyncData(user)</code>. This tells the UI the operation succeeded and here is the user data to render.</p>
<p>In the <code>onFailure</code> branch, we set <code>state = AsyncError(error, StackTrace.current)</code>. This tells the UI something went wrong so it can display the appropriate error state.</p>
<p>That's the entire notifier. It does exactly one thing: reflect the outcome of the use case as UI state.</p>
<p>Notice there's no token saving here. No navigation, caching, or analytics. All of that is already handled. The moment the use case called <code>_eventBus.publish(UserLoggedIn(...))</code> inside <code>execute</code>, every registered handler fired automatically. By the time <code>result</code> is returned to this notifier, all side effects are already done. The notifier just needs to update the UI.</p>
<p>This is the cleanest possible separation. The use case owns domain consequences. The notifier owns render state. Each has exactly one responsibility.</p>
<h3 id="heading-the-widget">The Widget</h3>
<pre><code class="language-cpp">class LoginPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final loginState = ref.watch(loginNotifierProvider);

    return Scaffold(
      body: loginState.when(
        data: (_) =&gt; const LoginForm(),
        loading: () =&gt; const Center(child: CircularProgressIndicator()),
        error: (error, _) =&gt; 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: 'user@example.com', password: 'secret'),
            );
          },
          child: const Text('Login'),
        ),
      ],
    );
  }
}
</code></pre>
<p><code>ref.watch(loginNotifierProvider)</code> subscribes this widget to the notifier's state. Every time <code>state</code> changes inside the notifier, <code>build</code> is called again and the widget re-renders.</p>
<p><code>loginState.when</code> is how you handle each case of <code>AsyncValue</code>. When the state is <code>AsyncData</code>, it renders the login form. When it is <code>AsyncLoading</code>, it renders a loading indicator. When it is <code>AsyncError</code>, it renders the error view.</p>
<p>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.</p>
<h3 id="heading-wiring-the-composition-root">Wiring the Composition Root</h3>
<p>All handler registrations happen once at app startup inside a Riverpod provider:</p>
<pre><code class="language-cpp">@riverpod
DomainEventBus eventBus(EventBusRef ref) {
  final bus = DomainEventBus();

  bus.register&lt;UserLoggedIn&gt;(
    TokenHandler(ref.read(secureStorageProvider)),
  );
  bus.register&lt;UserLoggedIn&gt;(
    UserCacheHandler(ref.read(userCacheProvider)),
  );
  bus.register&lt;UserLoggedIn&gt;(
    NavigationHandler(ref.read(navigationServiceProvider)),
  );
  bus.register&lt;UserLoggedIn&gt;(
    AnalyticsHandler(ref.read(analyticsServiceProvider)),
  );

  bus.register&lt;LoginFailed&gt;(
    AnalyticsFailureHandler(ref.read(analyticsServiceProvider)),
  );

  return bus;
}
</code></pre>
<p><code>eventBus</code> is a provider that creates the <code>DomainEventBus</code> and 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.</p>
<p>Every handler gets its dependencies injected via <code>ref.read</code>. Nothing is hardcoded. Everything is swappable in tests.</p>
<p>The <code>LoginUseCase</code> receives this event bus as a dependency through its own provider:</p>
<pre><code class="language-cpp">@riverpod
LoginUseCase loginUseCase(LoginUseCaseRef ref) {
  return LoginUseCase(
    repository: ref.read(authRepositoryProvider),
    eventBus: ref.read(eventBusProvider),
  );
}
</code></pre>
<p>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.</p>
<p>Adding a new side effect to login means creating a new handler class and adding one <code>bus.register</code> line in the composition root. The notifier, the use case logic, the widget, and every existing handler remain completely untouched.</p>
<h2 id="heading-testing-the-observer-architecture">Testing the Observer Architecture</h2>
<p>One of the most significant advantages of this architecture is how clearly it separates test concerns. Each layer has its own focused test scope.</p>
<h3 id="heading-testing-the-use-case">Testing the Use Case</h3>
<pre><code class="language-cpp">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(() =&gt; mockRepository.login(any())).thenAnswer((_) async =&gt; user);

      await useCase.execute(LoginRequest(email: 'a@b.com', password: '123'));

      verify(() =&gt; mockEventBus.publish(any&lt;UserLoggedIn&gt;())).called(1);
    });

    test('publishes LoginFailed event on error', () async {
      when(() =&gt; mockRepository.login(any()))
          .thenThrow(AppException.unauthorized(message: 'Invalid credentials'));

      await useCase.execute(LoginRequest(email: 'a@b.com', password: 'wrong'));

      verify(() =&gt; mockEventBus.publish(any&lt;LoginFailed&gt;())).called(1);
    });
  });
}
</code></pre>
<p>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.</p>
<h3 id="heading-testing-each-handler">Testing Each Handler</h3>
<pre><code class="language-cpp">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(
        () =&gt; mockStorage.write(key: 'auth_token', value: 'abc123'),
      ).called(1);
    });
  });
}
</code></pre>
<p>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.</p>
<h3 id="heading-testing-the-notifier">Testing the Notifier</h3>
<pre><code class="language-cpp">void main() {
  group('LoginNotifier', () {
    test('transitions from loading to data on success', () async {
      final mockUseCase = MockLoginUseCase();
      final user = UserDto(id: '1', token: 'token123');

      when(() =&gt; mockUseCase.execute(any()))
          .thenAnswer((_) async =&gt; Result.success(user));

      final container = ProviderContainer(overrides: [
        loginUseCaseProvider.overrideWithValue(mockUseCase),
      ]);

      final notifier = container.read(loginNotifierProvider.notifier);

      await notifier.login(LoginRequest(email: 'a@b.com', password: '123'));

      expect(
        container.read(loginNotifierProvider),
        isA&lt;AsyncData&lt;UserDto?&gt;&gt;(),
      );
    });

    test('transitions from loading to error on failure', () async {
      final mockUseCase = MockLoginUseCase();
      final error = AppException.unauthorized(message: 'Invalid credentials');

      when(() =&gt; mockUseCase.execute(any()))
          .thenAnswer((_) async =&gt; Result.failure(error));

      final container = ProviderContainer(overrides: [
        loginUseCaseProvider.overrideWithValue(mockUseCase),
      ]);

      final notifier = container.read(loginNotifierProvider.notifier);

      await notifier.login(LoginRequest(email: 'a@b.com', password: 'wrong'));

      expect(
        container.read(loginNotifierProvider),
        isA&lt;AsyncError&gt;(),
      );
    });
  });
}
</code></pre>
<p>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.</p>
<h2 id="heading-when-to-use-the-observer-pattern">When to Use the Observer Pattern</h2>
<p>Use Observer when:</p>
<ul>
<li><p>One event needs to trigger multiple independent reactions</p>
</li>
<li><p>You want to add or remove reactions without modifying the event source</p>
</li>
<li><p>Side effects need to be decoupled from business logic</p>
</li>
<li><p>Each reaction should be independently testable</p>
</li>
<li><p>Multiple parts of the system need to react to the same state change</p>
</li>
<li><p>You are building a feature that will grow in number of side effects over time</p>
</li>
</ul>
<h2 id="heading-when-not-to-use-it">When Not to Use It</h2>
<p>Avoid Observer when:</p>
<ul>
<li><p>You have only one consumer and no realistic expectation of more</p>
</li>
<li><p>The relationship between producer and consumer is simple and direct</p>
</li>
<li><p>The pattern adds structural overhead without meaningful benefit</p>
</li>
<li><p>Streams, ChangeNotifier, or Riverpod's built-in reactivity already solve the problem naturally</p>
</li>
<li><p>Strict ordering of side effects is critical and fan-out makes that hard to guarantee</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>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?</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>Happy Coding!</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
