<?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[ Oluwaseyi Fatunmole - 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[ Oluwaseyi Fatunmole - freeCodeCamp.org ]]>
            </title>
            <link>https://www.freecodecamp.org/news/</link>
        </image>
        <generator>Eleventy</generator>
        <lastBuildDate>Mon, 07 Sep 2026 23:53:29 +0000</lastBuildDate>
        <atom:link href="https://www.freecodecamp.org/news/author/foluwaseyi/rss.xml" rel="self" type="application/rss+xml" />
        <ttl>60</ttl>
        
            <item>
                <title>
                    <![CDATA[ The Builder Design Pattern: A Better Approach to Complex Object Construction ]]>
                </title>
                <description>
                    <![CDATA[ Some objects are simple, like a string, number, or boolean. You create them in one line and move on. Other objects aren't simple at all, like a carousel widget that needs an item count, an item builde ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-builder-design-pattern-a-better-approach-to-complex-object-construction/</link>
                <guid isPermaLink="false">6a98992c210435845c785935</guid>
                
                    <category>
                        <![CDATA[ builder pattern ]]>
                    </category>
                
                    <category>
                        <![CDATA[ creational patterns ]]>
                    </category>
                
                    <category>
                        <![CDATA[ design patterns ]]>
                    </category>
                
                    <category>
                        <![CDATA[ design and architecture ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Wed, 02 Sep 2026 21:46:20 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/3f61ce13-e9fd-4cc5-96a7-6c68b56048ef.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Some objects are simple, like a string, number, or boolean. You create them in one line and move on.</p>
<p>Other objects aren't simple at all, like a carousel widget that needs an item count, an item builder function, a controller, a height, a viewport fraction, autoplay settings, page change callbacks, and infinite scroll configuration. Or like an HTTP request that needs a URL, headers, authentication tokens, a body, a timeout, and retry logic. Or a notification that needs a title, body, icon, channel, priority, sound, vibration, and action buttons.</p>
<p>When you need to construct objects like these, the naïve approach is a constructor with many parameters. It works, but it creates problems that compound as the object grows more complex. Parameters become hard to tell apart. Optional parameters require null checks everywhere. The order of arguments matters and is easy to get wrong. The constructor call becomes a wall of values that nobody wants to read or maintain.</p>
<p>The Builder Design Pattern solves this. It separates the construction of a complex object from its representation, allowing the same construction process to create different configurations through a readable, step-by-step interface.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-is-the-builder-pattern">What is the Builder 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-method-chaining-the-fluent-interface">Method Chaining: The Fluent Interface</a></p>
</li>
<li><p><a href="#heading-real-world-example-one-flutter-carousel-builder">Real World Example One: Flutter Carousel Builder</a></p>
</li>
<li><p><a href="#heading-real-world-example-two-http-request-builder">Real World Example Two: HTTP Request Builder</a></p>
</li>
<li><p><a href="#heading-the-builder-pattern-in-c">The Builder Pattern in C#</a></p>
</li>
<li><p><a href="#heading-builder-vs-constructor-vs-factory">Builder vs Constructor vs Factory</a></p>
</li>
<li><p><a href="#heading-when-to-use-the-builder-pattern">When to Use the Builder 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-prerequisites">Prerequisites</h2>
<p>Before reading this article, you should be comfortable with:</p>
<ul>
<li><p>Object-oriented programming: classes, constructors, and methods</p>
</li>
<li><p>What a design pattern is at a conceptual level</p>
</li>
<li><p>Basic Dart or C# syntax</p>
</li>
</ul>
<p>You don't need prior experience with design patterns. This article introduces the Builder pattern from first principles.</p>
<h2 id="heading-what-is-the-builder-pattern">What is the Builder Pattern?</h2>
<p>The Builder Pattern is a creational design pattern. Creational patterns deal with how objects are created. The Builder pattern specifically deals with the construction of complex objects that require many configuration steps.</p>
<p>The pattern separates two concerns that are often tangled together in simpler code: what an object is, and how it's built. The object holds its own data and behavior. The Builder holds the construction logic and accumulates the configuration step by step before producing the final object.</p>
<p>The result is a construction process that reads like a description of what you're building rather than a list of values to pass to a constructor.</p>
<h2 id="heading-the-problem-it-solves">The Problem It Solves</h2>
<p>Here's what constructing a complex widget looks like without the Builder pattern:</p>
<pre><code class="language-csharp">// constructing a carousel directly — hard to read, easy to get wrong
CarouselSlider.builder(
  options: CarouselOptions(
    height: 200,
    viewportFraction: 0.97,
    enableInfiniteScroll: false,
    autoPlayCurve: Curves.easeIn,
    enlargeCenterPage: true,
    pauseAutoPlayOnManualNavigate: true,
    onPageChanged: onPageChanged,
    autoPlay: false,
  ),
  itemBuilder: (context, index, realIndex) =&gt; AdCard(ad: ads[index]),
  itemCount: ads.length,
)
</code></pre>
<p>This works. But look at what happens when you need to create two different carousels in the same screen: one for ads and one for account balances. Both need different heights, viewport fractions, item builders, and item counts. You copy the entire construction block, modify the values, and now you have two walls of configuration that are visually similar but subtly different.</p>
<p>When a new requirement comes in to add autoplay to the ads carousel but not the balance carousel, you have to find the right block, modify it carefully, and hope you're modifying the right one.</p>
<p>The deeper problem is that the construction logic is scattered across the codebase. Every place a carousel is created knows all the details of carousel construction. There's no single place where that knowledge lives.</p>
<p>The Builder pattern collects construction knowledge into one place and exposes it through a clean interface.</p>
<h2 id="heading-core-components">Core Components</h2>
<p>The Builder pattern has three components.</p>
<h3 id="heading-the-product">The Product</h3>
<p>The complex object being built. It doesn't know about the Builder. It holds its configuration and has behavior based on that configuration. The Product is often constructed with a private constructor so it can only be created by its Builder.</p>
<h3 id="heading-the-builder">The Builder</h3>
<p>The class responsible for accumulating configuration and producing the Product. Each method on the Builder configures one aspect of the Product and returns the Builder itself. This return is what enables method chaining. The final method on the Builder produces the completed Product.</p>
<h3 id="heading-the-director-optional">The Director (optional)</h3>
<p>A class that knows how to use a Builder to produce specific pre-configured Products. The Director encodes the knowledge of how to build common configurations so callers don't need to know the details. In practice, a Factory method often serves this role.</p>
<h2 id="heading-method-chaining-the-fluent-interface">Method Chaining: The Fluent Interface</h2>
<p>Method chaining is the technique that makes Builder code read naturally. Each Builder method returns <code>this</code> (the Builder itself) so the next method call can follow immediately on the same line or the next line.</p>
<pre><code class="language-csharp">// without method chaining
final builder = RequestBuilder();
builder.setUrl('https://api.example.com/users');
builder.setMethod('POST');
builder.addHeader('Authorization', 'Bearer $token');
builder.setBody({'name': 'John'});
final request = builder.build();

// with method chaining
final request = RequestBuilder()
    .setUrl('https://api.example.com/users')
    .setMethod('POST')
    .addHeader('Authorization', 'Bearer $token')
    .setBody({'name': 'John'})
    .build();
</code></pre>
<p>Both produce exactly the same result. The chained version reads like a sentence describing the request. The unchained version is a sequence of imperative statements.</p>
<p>Method chaining is sometimes called a Fluent Interface. The name comes from how the code reads: fluently, like natural language, from left to right or top to bottom.</p>
<h2 id="heading-real-world-example-one-flutter-carousel-builder">Real World Example One: Flutter Carousel Builder</h2>
<p>This is a real production implementation from a Flutter fintech application. The app needs to show two different carousels on the dashboard: one for promotional ads and one for account balances. Each carousel has a different configuration but shares the same underlying construction mechanism.</p>
<h3 id="heading-the-configuration-objects">The Configuration Objects</h3>
<pre><code class="language-csharp">import 'package:flutter/widgets.dart';
import 'package:equatable/equatable.dart';
import 'package:carousel_slider/carousel_slider.dart';

class CarouselArgs extends Equatable {
  final CarouselSliderController? carouselController;
  final int itemCount;
  final Widget Function(BuildContext, int, int) itemBuilder;
  final CarouselOptions options;

  const CarouselArgs({
    this.carouselController,
    required this.itemCount,
    required this.itemBuilder,
    required this.options,
  });

  @override
  List&lt;Object?&gt; get props =&gt; [
        carouselController,
        itemCount,
        itemBuilder,
        options,
      ];
}

class CarouselOptions extends Equatable {
  final double? height;
  final double? viewPortFraction;
  final bool? enableInfiniteScroll;
  final bool? enlargeCenterPage;
  final bool? pauseAutoPlayOnManualNavigate;
  final bool? autoplay;
  final Curve? autoplayCurve;
  final void Function(int, CarouselPageChangedReason)? onPageChanged;

  const CarouselOptions({
    this.height,
    this.viewPortFraction,
    this.enableInfiniteScroll,
    this.enlargeCenterPage,
    this.pauseAutoPlayOnManualNavigate,
    this.autoplay,
    this.autoplayCurve,
    this.onPageChanged,
  });

  @override
  List&lt;Object?&gt; get props =&gt; [
        height,
        viewPortFraction,
        enableInfiniteScroll,
        enlargeCenterPage,
        pauseAutoPlayOnManualNavigate,
        autoplay,
        autoplayCurve,
        onPageChanged,
      ];
}
</code></pre>
<p><code>CarouselArgs</code> and <code>CarouselOptions</code> are the configuration objects. They hold all the data needed to construct a carousel. They're simple data containers with no construction logic of their own.</p>
<h3 id="heading-the-product">The Product</h3>
<pre><code class="language-csharp">import 'package:flutter/material.dart';
import 'package:carousel_slider/carousel_slider.dart' as n;

class CustomCarousel extends StatelessWidget {
  final CarouselArgs dto;

  // private constructor only the Builder can create this widget
  CustomCarousel._builder(CustomCarouselBuilder builder)
      : dto = builder._dto!;

  @override
  Widget build(BuildContext context) {
    return n.CarouselSlider.builder(
      options: n.CarouselOptions(
        height: dto.options.height,
        viewportFraction: dto.options.viewPortFraction!,
        enableInfiniteScroll: dto.options.enableInfiniteScroll!,
        enlargeCenterPage: dto.options.enlargeCenterPage,
        onPageChanged: dto.options.onPageChanged,
        autoPlayCurve: dto.options.autoplayCurve!,
        autoPlay: dto.options.autoplay!,
      ),
      itemBuilder: dto.itemBuilder,
      itemCount: dto.itemCount,
    );
  }
}
</code></pre>
<p><code>CustomCarousel</code> is the Product. Its constructor is private: <code>CustomCarousel._builder</code>. The underscore prefix and the named constructor ensure that nobody outside this class can instantiate a <code>CustomCarousel</code> directly. The only way to create one is through the Builder.</p>
<p>This is intentional. It enforces that all carousel construction goes through the Builder, where the configuration is validated and assembled consistently.</p>
<h3 id="heading-the-builder">The Builder</h3>
<pre><code class="language-csharp">class CustomCarouselBuilder {
  BuildContext? _context;
  CarouselArgs? _dto;

  CustomCarouselBuilder setArgs(BuildContext context, CarouselArgs value) {
    _context = context;
    _dto = value;
    return this;
  }

  Widget get buildCarousel =&gt;
      CustomCarousel._builder(this).build(_context!);
}
</code></pre>
<p><code>CustomCarouselBuilder</code> is the Builder. It accumulates the <code>BuildContext</code> and the <code>CarouselArgs</code> through <code>setArgs</code>. The <code>setArgs</code> method returns <code>this</code>, enabling the call to be chained.</p>
<p><code>buildCarousel</code> is the terminal step. It calls the private constructor of <code>CustomCarousel</code>, passing itself as the argument, and then calls <code>build</code> to produce the final widget. The carousel can't be built until both the context and the args have been provided.</p>
<h3 id="heading-the-director-a-factory-that-uses-the-builder">The Director: A Factory That Uses the Builder</h3>
<pre><code class="language-csharp">import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:carousel_slider/carousel_slider.dart' as n;

class CarouselWidgetFactory {
  static CustomCarouselBuilder showCarouselAds(
    BuildContext context, {
    void Function(int, n.CarouselPageChangedReason)? onPageChanged,
  }) =&gt;
      CustomCarouselBuilder().setArgs(
        context,
        CarouselArgs(
          itemCount: context.read&lt;DashboardLogic&gt;().dashboardAds.length,
          itemBuilder: (context, index, realIndex) =&gt; DashboardAdsList(
            dto: context.read&lt;DashboardLogic&gt;().dashboardAds[index],
          ),
          options: CarouselOptions(
            height: 200,
            viewPortFraction: 0.97,
            enableInfiniteScroll: false,
            autoplayCurve: Curves.easeIn,
            enlargeCenterPage: true,
            pauseAutoPlayOnManualNavigate: true,
            onPageChanged: onPageChanged,
            autoplay: false,
          ),
        ),
      );

  static CustomCarouselBuilder showCarouselAccountBalance(
    BuildContext context, {
    void Function(int, n.CarouselPageChangedReason)? onPageChanged,
  }) =&gt;
      CustomCarouselBuilder().setArgs(
        context,
        CarouselArgs(
          itemCount: context.read&lt;DashboardLogic&gt;().allBalances.length,
          itemBuilder: (context, index, realIndex) =&gt; BalanceList(
            dto: context.read&lt;DashboardLogic&gt;().allBalances[index],
          ),
          options: CarouselOptions(
            height: 230,
            viewPortFraction: 1,
            enableInfiniteScroll: false,
            autoplayCurve: Curves.decelerate,
            enlargeCenterPage: true,
            pauseAutoPlayOnManualNavigate: true,
            onPageChanged: onPageChanged,
            autoplay: false,
          ),
        ),
      );
}
</code></pre>
<p><code>CarouselWidgetFactory</code> is the Director. It knows exactly how to configure the Builder for each specific carousel type. The knowledge of what a dashboard ads carousel looks like (height 200, viewport 0.97, easeIn curve) lives in one place. The knowledge of what an account balance carousel looks like (height 230, viewport 1, decelerate curve) lives in one place.</p>
<p>A developer who needs to add a new carousel type adds one static method to <code>CarouselWidgetFactory</code>. They don't need to understand the internals of <code>CustomCarouselBuilder</code> or <code>CustomCarousel</code>. They describe what they want using the Factory.</p>
<h3 id="heading-using-it">Using It</h3>
<pre><code class="language-csharp">class DashboardPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // the factory creates the right builder configuration
        // buildCarousel produces the final widget
        CarouselWidgetFactory.showCarouselAds(context).buildCarousel,
        const SizedBox(height: 16),
        CarouselWidgetFactory.showCarouselAccountBalance(context).buildCarousel,
      ],
    );
  }
}
</code></pre>
<p>Two carousels, two lines. The calling code has zero knowledge of carousel configuration. It doesn't know about viewport fractions, autoplay curves, or item builders. It calls the Factory, which uses the Builder, which produces the Product. Each layer knows only what it needs to know.</p>
<h2 id="heading-real-world-example-two-http-request-builder">Real World Example Two: HTTP Request Builder</h2>
<p>The carousel example shows Builder for widget construction. This second example shows it for non-UI object construction: building HTTP requests. This is a common pattern in data layers and API clients.</p>
<h3 id="heading-the-product">The Product</h3>
<pre><code class="language-csharp">class ApiRequest {
  final String url;
  final String method;
  final Map&lt;String, String&gt; headers;
  final Map&lt;String, dynamic&gt;? body;
  final Duration timeout;
  final int maxRetries;

  // private constructor — only the Builder can create ApiRequest
  ApiRequest._({
    required this.url,
    required this.method,
    required this.headers,
    this.body,
    required this.timeout,
    required this.maxRetries,
  });
}
</code></pre>
<p><code>ApiRequest</code> holds everything needed to make an HTTP request. Its private constructor ensures it's always created through the Builder, where defaults are applied and validation happens.</p>
<h3 id="heading-the-builder">The Builder</h3>
<pre><code class="language-csharp">class ApiRequestBuilder {
  String? _url;
  String _method = 'GET';
  final Map&lt;String, String&gt; _headers = {};
  Map&lt;String, dynamic&gt;? _body;
  Duration _timeout = const Duration(seconds: 30);
  int _maxRetries = 0;

  ApiRequestBuilder url(String url) {
    _url = url;
    return this;
  }

  ApiRequestBuilder method(String method) {
    _method = method;
    return this;
  }

  ApiRequestBuilder header(String key, String value) {
    _headers[key] = value;
    return this;
  }

  ApiRequestBuilder bearerToken(String token) {
    _headers['Authorization'] = 'Bearer $token';
    return this;
  }

  ApiRequestBuilder contentType(String type) {
    _headers['Content-Type'] = type;
    return this;
  }

  ApiRequestBuilder body(Map&lt;String, dynamic&gt; body) {
    _body = body;
    return this;
  }

  ApiRequestBuilder timeout(Duration timeout) {
    _timeout = timeout;
    return this;
  }

  ApiRequestBuilder withRetries(int maxRetries) {
    _maxRetries = maxRetries;
    return this;
  }

  ApiRequest build() {
    if (_url == null || _url!.isEmpty) {
      throw ArgumentError('URL is required to build an ApiRequest');
    }

    return ApiRequest._(
      url: _url!,
      method: _method,
      headers: Map.unmodifiable(_headers),
      body: _body,
      timeout: _timeout,
      maxRetries: _maxRetries,
    );
  }
}
</code></pre>
<p>Each method on <code>ApiRequestBuilder</code> sets one configuration value and returns <code>this</code>. The <code>build()</code> method is the terminal step. It validates that required fields are present and constructs the immutable <code>ApiRequest</code>.</p>
<p>Notice that <code>_method</code>, <code>_timeout</code>, and <code>_maxRetries</code> all have sensible defaults. A caller doesn't need to specify these unless they want to override the defaults. This is one of the key advantages of the Builder over a constructor: optional configuration is genuinely optional, with no null checks or default parameter workarounds.</p>
<h3 id="heading-using-it">Using It</h3>
<pre><code class="language-csharp">// a standard authenticated POST request
final createUserRequest = ApiRequestBuilder()
    .url('https://api.example.com/users')
    .method('POST')
    .bearerToken(authToken)
    .contentType('application/json')
    .body({'name': 'Oluwaseyi', 'email': 'seyi@example.com'})
    .timeout(const Duration(seconds: 15))
    .build();

// a GET request with retry logic
final getUserRequest = ApiRequestBuilder()
    .url('https://api.example.com/users/$userId')
    .bearerToken(authToken)
    .withRetries(3)
    .build();

// a request with custom headers for a third-party service
final webhookRequest = ApiRequestBuilder()
    .url('https://webhook.example.com/events')
    .method('POST')
    .header('X-API-Key', apiKey)
    .header('X-Webhook-Secret', webhookSecret)
    .contentType('application/json')
    .body(eventPayload)
    .timeout(const Duration(seconds: 5))
    .build();
</code></pre>
<p>Each request reads like a description of itself. The URL, the method, the authentication, the body, and the timeout. You can read any of these and understand immediately what kind of request it is and what it contains.</p>
<p>Compare this to calling a constructor directly:</p>
<pre><code class="language-csharp">// without Builder — hard to read, parameter order matters
final request = ApiRequest._(
  url: 'https://api.example.com/users',
  method: 'POST',
  headers: {
    'Authorization': 'Bearer $authToken',
    'Content-Type': 'application/json',
  },
  body: {'name': 'Oluwaseyi', 'email': 'seyi@example.com'},
  timeout: const Duration(seconds: 15),
  maxRetries: 0,
);
</code></pre>
<p>The constructor version requires you to know every field and its order. The Builder version lets you specify only what you need and reads like documentation.</p>
<h2 id="heading-the-builder-pattern-in-c">The Builder Pattern in C#</h2>
<p>The same pattern in C# demonstrates that this is a universal design principle, not a Dart-specific technique. C# is particularly expressive for Builder implementations because of its method chaining conventions.</p>
<h3 id="heading-http-request-builder-in-c">HTTP Request Builder in C#</h3>
<pre><code class="language-csharp">public class ApiRequest
{
    public string Url { get; }
    public string Method { get; }
    public Dictionary&lt;string, string&gt; Headers { get; }
    public object? Body { get; }
    public TimeSpan Timeout { get; }
    public int MaxRetries { get; }

    // private constructor
    private ApiRequest(
        string url,
        string method,
        Dictionary&lt;string, string&gt; headers,
        object? body,
        TimeSpan timeout,
        int maxRetries)
    {
        Url = url;
        Method = method;
        Headers = headers;
        Body = body;
        Timeout = timeout;
        MaxRetries = maxRetries;
    }

    public static ApiRequestBuilder Create() =&gt; new ApiRequestBuilder();
}

public class ApiRequestBuilder
{
    private string? _url;
    private string _method = "GET";
    private readonly Dictionary&lt;string, string&gt; _headers = new();
    private object? _body;
    private TimeSpan _timeout = TimeSpan.FromSeconds(30);
    private int _maxRetries = 0;

    public ApiRequestBuilder Url(string url)
    {
        _url = url;
        return this;
    }

    public ApiRequestBuilder Method(string method)
    {
        _method = method;
        return this;
    }

    public ApiRequestBuilder Header(string key, string value)
    {
        _headers[key] = value;
        return this;
    }

    public ApiRequestBuilder BearerToken(string token)
    {
        _headers["Authorization"] = $"Bearer {token}";
        return this;
    }

    public ApiRequestBuilder ContentType(string contentType)
    {
        _headers["Content-Type"] = contentType;
        return this;
    }

    public ApiRequestBuilder Body(object body)
    {
        _body = body;
        return this;
    }

    public ApiRequestBuilder Timeout(TimeSpan timeout)
    {
        _timeout = timeout;
        return this;
    }

    public ApiRequestBuilder WithRetries(int maxRetries)
    {
        _maxRetries = maxRetries;
        return this;
    }

    public ApiRequest Build()
    {
        if (string.IsNullOrEmpty(_url))
            throw new ArgumentException("URL is required");

        return new ApiRequest(
            _url!,
            _method,
            new Dictionary&lt;string, string&gt;(_headers),
            _body,
            _timeout,
            _maxRetries
        );
    }
}
</code></pre>
<h3 id="heading-using-it-in-c">Using It in C#</h3>
<pre><code class="language-csharp">// authenticated POST request
var createUserRequest = ApiRequest.Create()
    .Url("https://api.example.com/users")
    .Method("POST")
    .BearerToken(authToken)
    .ContentType("application/json")
    .Body(new { name = "Oluwaseyi", email = "seyi@example.com" })
    .Timeout(TimeSpan.FromSeconds(15))
    .Build();

// GET with retry logic
var getUserRequest = ApiRequest.Create()
    .Url($"https://api.example.com/users/{userId}")
    .BearerToken(authToken)
    .WithRetries(3)
    .Build();
</code></pre>
<p>The pattern is identical. The method names are capitalized following C# conventions. The <code>Build()</code> method is the terminal step. The private constructor is enforced. The result reads exactly like its Dart equivalent.</p>
<h3 id="heading-a-ui-builder-in-c-aspnet">A UI Builder in C# (ASP.NET)</h3>
<p>The Builder pattern also appears naturally in .NET for constructing complex objects in backend systems. Here's a notification builder:</p>
<pre><code class="language-csharp">public class Notification
{
    public string Title { get; }
    public string Body { get; }
    public string? ImageUrl { get; }
    public NotificationPriority Priority { get; }
    public Dictionary&lt;string, string&gt; Data { get; }
    public bool Silent { get; }

    private Notification(
        string title,
        string body,
        string? imageUrl,
        NotificationPriority priority,
        Dictionary&lt;string, string&gt; data,
        bool silent)
    {
        Title = title;
        Body = body;
        ImageUrl = imageUrl;
        Priority = priority;
        Data = data;
        Silent = silent;
    }

    public static NotificationBuilder Builder(string title, string body)
        =&gt; new NotificationBuilder(title, body);
}

public class NotificationBuilder
{
    private readonly string _title;
    private readonly string _body;
    private string? _imageUrl;
    private NotificationPriority _priority = NotificationPriority.Default;
    private readonly Dictionary&lt;string, string&gt; _data = new();
    private bool _silent = false;

    internal NotificationBuilder(string title, string body)
    {
        _title = title;
        _body = body;
    }

    public NotificationBuilder WithImage(string imageUrl)
    {
        _imageUrl = imageUrl;
        return this;
    }

    public NotificationBuilder WithPriority(NotificationPriority priority)
    {
        _priority = priority;
        return this;
    }

    public NotificationBuilder WithData(string key, string value)
    {
        _data[key] = value;
        return this;
    }

    public NotificationBuilder AsSilent()
    {
        _silent = true;
        return this;
    }

    public Notification Build() =&gt; new Notification(
        _title,
        _body,
        _imageUrl,
        _priority,
        new Dictionary&lt;string, string&gt;(_data),
        _silent
    );
}

// usage
var notification = Notification
    .Builder("New Transaction", "You received NGN 50,000")
    .WithPriority(NotificationPriority.High)
    .WithData("transaction_id", "txn_001")
    .WithData("type", "credit")
    .Build();

var silentNotification = Notification
    .Builder("Background Sync", "")
    .AsSilent()
    .WithData("sync_type", "full")
    .Build();
</code></pre>
<h2 id="heading-builder-vs-constructor-vs-factory">Builder vs Constructor vs Factory</h2>
<p>Understanding when to reach for a Builder versus a constructor or a Factory method requires understanding what problem each one solves.</p>
<p>A <strong>constructor</strong> is the right choice when the object is simple enough that all its parameters can be understood at a glance and there are few optional configurations. A User with an id, name, and email doesn't need a Builder.</p>
<p>A <strong>Factory method</strong> is the right choice when you need to control which type of object is created, or when creation requires logic that determines which concrete type to instantiate. A Repository.create() that returns either a SqlRepository or a HiveRepository based on the environment is a Factory.</p>
<p>A <strong>Builder</strong> is the right choice when the object has many optional or complex configuration parameters, when the construction requires multiple steps, when you want to prevent the creation of invalid objects by deferring construction until all required parameters are present, or when you want construction code to read clearly and be self-documenting.</p>
<p>The carousel example uses both Builder and Factory together deliberately. The Factory provides named, pre-configured entry points (showCarouselAds, showCarouselAccountBalance). The Builder handles the step-by-step construction of the complex configuration. Each pattern does its job.</p>
<h2 id="heading-when-to-use-the-builder-pattern">When to Use the Builder Pattern</h2>
<p>There are various solid use cases for the Builder pattern.</p>
<p>Use it when the object being constructed has many parameters, especially many optional ones. Named constructors with ten optional parameters are hard to read and easy to misconfigure.</p>
<p>It's also a good choice when the construction requires multiple steps that should be validated before the object is created. A Builder can enforce that required fields are present before calling <code>build()</code>.</p>
<p>Choose it when you want the construction code to be self-documenting. Method chaining with descriptive names reads like documentation. A reader can understand what is being built without knowing the internals.</p>
<p>It's helpful when you need different representations of the same object. The same Builder can be used to create a test request, a staging request, and a production request by changing which methods are called, without modifying the Request class itself.</p>
<p>And it works well when you want to prevent the creation of invalid objects. By making the Product's constructor private and putting validation in the Builder's <code>build()</code> method, you ensure that invalid objects simply can't be created.</p>
<h2 id="heading-when-not-to-use-it">When Not to Use It</h2>
<p>Avoid the Builder pattern when the object is simple and its constructor is already clear. Adding a Builder to a class with two required parameters is over-engineering that adds complexity without adding value.</p>
<p>It's also not a great choice when immutability isn't a concern and the object can be configured after creation through property setters. Some objects benefit from simple post-construction configuration rather than Builder-pattern construction.</p>
<p>And it's best to avoid it when the construction steps have strict ordering that a linear Builder can't represent. If step B absolutely must know the result of step A before it can run, a different pattern may be more appropriate.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The Builder Design Pattern addresses a problem that every developer encounters as their objects grow more complex. Constructors with many parameters become walls of values that are hard to read, hard to maintain, and easy to misconfigure. Optional parameters require null checks and default value workarounds that obscure the intent of the code.</p>
<p>The Builder separates the construction of a complex object from the object itself. Configuration accumulates step by step through descriptive method calls. Construction happens in one terminal step that validates and produces the final object. The Product's private constructor ensures that bypassing the Builder isn't possible.</p>
<p>The carousel example from a real Flutter fintech application shows this in a UI context: a Builder that accumulates widget configuration, a Factory that provides pre-configured Builder calls for specific carousel types, and a Product that can only be constructed through its Builder. Adding a new carousel type means one new Factory method. Changing carousel configuration means changing the relevant Factory method. The calling code never touches carousel internals.</p>
<p>The HTTP Request Builder shows the same pattern in a data layer context: step by step configuration through method chaining, sensible defaults for optional values, validation before construction, and an immutable Product that can't be created in an invalid state.</p>
<p>Method chaining is what makes Builder code readable. Each method call returns the Builder, enabling the next call to follow immediately. The chain reads from left to right or top to bottom like a description of what's being built. This isn't just aesthetics. It's what makes Builder code self-documenting and maintainable over time.</p>
<p>Complex object construction is a problem every codebase will encounter. The Builder pattern is how experienced engineers solve it.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Feature Modularization in Flutter: How to Combine Clean Architecture and Domain-Driven Design for Self-Contained, Scalable Features ]]>
                </title>
                <description>
                    <![CDATA[ As an engineer working on a small team, your current structure could fly. But what if your team, all contributing to the same codebase, reaches 20 or more people at scale? You'll need careful design t ]]>
                </description>
                <link>https://www.freecodecamp.org/news/feature-modularization-in-flutter-combine-clean-architecture-and-domain-driven-design/</link>
                <guid isPermaLink="false">6a9754cacade799374a0231b</guid>
                
                    <category>
                        <![CDATA[ engineering-management ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ DDD ]]>
                    </category>
                
                    <category>
                        <![CDATA[ #Domain-Driven-Design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Clean Architecture ]]>
                    </category>
                
                    <category>
                        <![CDATA[ clean code ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Tue, 01 Sep 2026 22:42:18 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9dac98f7-f9bf-4597-bbd9-95e220cce9ec.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>As an engineer working on a small team, your current structure could fly. But what if your team, all contributing to the same codebase, reaches 20 or more people at scale? You'll need careful design thinking, seamless contribution workflows, and processes for keeping the codebase concise and compact.</p>
<p>You'll also need to be quite intentional in selecting your folder structure, architecture, and the patterns you use in your projects.</p>
<p>Most Flutter apps start the same way. In the beginning, you have a lib folder, a few screens, maybe a models directory, and a services file that handles everything. It works, the app ships, and everyone is happy.</p>
<p>Then the app grows. New features come in. The team expands. What was once a manageable codebase becomes a maze. Changing one thing breaks another. Nobody is sure where business logic actually lives. The models folder has three hundred files. The services file is six thousand lines long. New engineers take weeks to understand where to add anything.</p>
<p>This isn't a discipline problem. It's a structure problem. The app was never organized in a way that could absorb growth without collapsing.</p>
<p>Feature Modularization is the organizational strategy that prevents this collapse. It's not a new framework, and it's not a replacement for Clean Architecture or Domain-Driven Design. It takes the principles of both and applies them feature by feature, so that every piece of your application is self-contained, independently testable, and scalable without interference.</p>
<p>In this article, we'll cover the current issues you might encounter using the layer first pattern, how Domain-Driven Design actually works, some business rules with value objects, what Clean Architecture means, and how they all come together in feature modularization.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-feature-modularization-actually-is">What Feature Modularization Actually Is</a></p>
</li>
<li><p><a href="#heading-the-problem-with-layer-first-organization">The Problem With Layer-First Organization</a></p>
</li>
<li><p><a href="#heading-the-building-blocks-clean-architecture-meets-ddd">The Building Blocks: Clean Architecture Meets DDD</a></p>
</li>
<li><p><a href="#heading-entities-value-objects-and-dtos-in-a-modular-feature">Entities, Value Objects, and DTOs in a Modular Feature</a></p>
</li>
<li><p><a href="#heading-business-rules-and-where-they-live">Business Rules and Where They Live</a></p>
</li>
<li><p><a href="#heading-exception-handling-and-safe-state-propagation">Exception Handling and Safe State Propagation</a></p>
</li>
<li><p><a href="#heading-the-folder-structure">The Folder Structure</a></p>
</li>
<li><p><a href="#heading-real-world-example-one-the-employee-feature">Real World Example One: The Employee Feature</a></p>
</li>
<li><p><a href="#heading-real-world-example-two-the-payment-feature">Real World Example Two: The Payment Feature</a></p>
</li>
<li><p><a href="#heading-cross-feature-communication">Cross-Feature Communication</a></p>
</li>
<li><p><a href="#heading-patterns-that-enhance-modularization">Patterns That Enhance Modularization</a></p>
</li>
<li><p><a href="#heading-scaling-to-large-teams">Scaling to Large Teams</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before reading this article, you should be comfortable with:</p>
<ul>
<li><p>Building Flutter applications in Dart</p>
</li>
<li><p>What object-oriented programming is: classes, inheritance, interfaces</p>
</li>
<li><p>What layers mean in software architecture: separating UI from business logic from data access</p>
</li>
<li><p>Async programming in Dart: Futures and async/await</p>
</li>
</ul>
<p>You don't need prior experience with Clean Architecture or Domain-Driven Design. This article introduces the relevant concepts from both as they come up.</p>
<h2 id="heading-what-feature-modularization-actually-is">What Feature Modularization Actually Is</h2>
<p>Feature Modularization is the practice of organizing your application around features, not around technical layers.</p>
<p>Every feature in your application contains everything it needs to function: its domain model, business rules, data access, and UI. The feature is a vertical slice through the entire application stack. It doesn't depend on other features to function. It doesn't expose its internals to other features. It stands alone.</p>
<p>This is fundamentally different from how most apps are initially organized, where all the domain code lives in one folder, all the data access lives in another folder, and all the UI lives in a third folder, regardless of which feature that code belongs to.</p>
<p>Feature Modularization isn't Clean Architecture. Clean Architecture defines what the layers are and the rules that govern how they interact. It tells you that the domain layer can't depend on the infrastructure layer, that business rules can't know about the UI, and that data flows inward through defined boundaries.</p>
<p>Feature Modularization isn't Domain-Driven Design either. DDD gives you the vocabulary for modeling complex business domains. It introduces entities, value objects, aggregates, domain services, and bounded contexts as tools for capturing business complexity in code.</p>
<p>Feature Modularization takes both of these and combines them. It uses Clean Architecture's layering rules and DDD's modeling vocabulary, and it applies them at the feature level. Each feature gets its own domain layer, its own application layer, its own infrastructure layer, and its own presentation layer. The rules from Clean Architecture govern how those layers interact within the feature. The concepts from DDD govern how the domain model is structured inside the domain layer.</p>
<p>The result is a codebase that can grow indefinitely without any single part of it becoming too large to understand or modify safely.</p>
<h2 id="heading-the-problem-with-layer-first-organization">The Problem With Layer-First Organization</h2>
<p>To understand why Feature Modularization matters, you need to see what the alternative looks like at scale.</p>
<p>In a layer-first organization, the folder structure groups code by technical role:</p>
<pre><code class="language-plaintext">lib/
  domain/
    employee.dart
    payment.dart
    product.dart
    notification.dart
    user.dart
    ... 47 more files

  application/
    get_employee_usecase.dart
    process_payment_usecase.dart
    get_products_usecase.dart
    send_notification_usecase.dart
    ... 83 more use cases

  infrastructure/
    employee_repository_impl.dart
    payment_repository_impl.dart
    product_datasource.dart
    notification_service_impl.dart
    ... 91 more implementations

  presentation/
    employee_page.dart
    payment_page.dart
    product_list_page.dart
    ... 200 more screens and widgets
</code></pre>
<p>This works fine at small scale. At medium scale it starts to show cracks. At large scale it becomes genuinely painful.</p>
<p>When a developer needs to modify the Employee feature, they touch files in four separate top-level folders. A change to the Employee entity in the domain folder requires navigating to the application folder for the use case, then the infrastructure folder for the repository implementation, and then the presentation folder for the UI. These folders aren't next to each other. They're separated by all the other features sharing those folders.</p>
<p>Understanding any single feature requires mentally assembling it from pieces scattered across the entire codebase. Onboarding a new engineer to the Employee feature means showing them four different locations before they can see the complete picture.</p>
<p>Testing the Employee feature in isolation is difficult because its pieces aren't isolated. They share folders and sometimes share dependencies with other features in ways that make it hard to draw a clean boundary.</p>
<p>Feature Modularization solves this by keeping everything that belongs to a feature together.</p>
<h2 id="heading-the-building-blocks-clean-architecture-meets-ddd">The Building Blocks: Clean Architecture Meets DDD</h2>
<p>Before looking at the folder structure and code, you need to understand what Clean Architecture and DDD each contribute to Feature Modularization and why both are necessary.</p>
<h3 id="heading-what-clean-architecture-contributes">What Clean Architecture Contributes</h3>
<p>Clean Architecture organizes code into layers with a strict dependency rule: inner layers never depend on outer layers. The domain is the innermost layer. The application layer wraps the domain. The infrastructure layer is outermost.</p>
<p>In a Flutter application, this means a few things:</p>
<p>The <strong>domain layer</strong> contains entities, value objects, and repository interfaces. It has zero dependencies on Flutter, on HTTP libraries, on local databases, or on any external package. It's pure Dart, and it's the business logic of the feature, completely isolated from how the app is built or deployed.</p>
<p>The <strong>application layer</strong> contains use cases. A use case orchestrates domain objects to accomplish a specific business task. It depends only on the domain layer. It knows about entities and repositories but doesn't know about HTTP or SQLite or Riverpod.</p>
<p>The <strong>infrastructure layer</strong> contains the concrete implementations of repository interfaces defined in the domain. It knows about HTTP clients, local databases, and external services. It depends on the domain layer interfaces but the domain layer never depends on it.</p>
<p>The <strong>presentation layer</strong> contains the UI: widgets, notifiers, and state. It depends on the application layer through use cases. It reacts to state and has no business logic.</p>
<p>This dependency direction ensures that business rules are never corrupted by infrastructure details. Changing your HTTP client from Dio to http doesn't require touching a single domain entity. Changing your state management from Riverpod to BLoC doesn't require touching a single use case.</p>
<h3 id="heading-what-domain-driven-design-contributes">What Domain-Driven Design Contributes</h3>
<p>DDD provides the vocabulary for modeling the domain layer properly.</p>
<p>An <strong>Entity</strong> is a domain object with identity. It has an ID that distinguishes it from other entities of the same type. An Employee entity is identified by its employee ID. Two employees with the same name are still different entities because they have different IDs. Entities can have state that changes over time, and they enforce rules about how that state can change.</p>
<p>A <strong>Value Object</strong> wraps a single piece of data and enforces its intrinsic validity. An EmployeeId is a Value Object, as is an email address or monetary amount. Value Objects are immutable. If the data is invalid, the Value Object throws an exception at construction time, before invalid data can enter the domain.</p>
<p>A <strong>Domain Service</strong> handles business logic that doesn't naturally belong to a single entity. If a rule spans multiple entities or requires coordination between entities in ways that don't fit on any single entity, that logic belongs in a Domain Service.</p>
<p>A <strong>Repository</strong> is an interface defined in the domain layer that describes how to persist and retrieve domain objects. The domain knows what operations it needs. The infrastructure layer provides the concrete implementation. The domain never knows which database or API is behind the repository.</p>
<h2 id="heading-entities-value-objects-and-dtos-in-a-modular-feature">Entities, Value Objects, and DTOs in a Modular Feature</h2>
<p>Understanding the distinction between these three types is fundamental to feature modularization. Getting this wrong leads to business rules leaking into the wrong layers.</p>
<h3 id="heading-value-objects">Value Objects</h3>
<p>A Value Object wraps a single piece of data and enforces that the data is always valid. It throws immediately when invalid data is provided. This means invalid data can never exist inside your domain.</p>
<pre><code class="language-dart">class EmployeeId {
  final String value;

  EmployeeId(this.value) {
    if (value.isEmpty) {
      throw DomainException('Employee ID cannot be empty');
    }
    if (value.length &lt; 4) {
      throw DomainException('Employee ID must be at least 4 characters');
    }
  }

  @override
  bool operator ==(Object other) =&gt;
      other is EmployeeId &amp;&amp; other.value == value;

  @override
  int get hashCode =&gt; value.hashCode;

  @override
  String toString() =&gt; value;
}
</code></pre>
<pre><code class="language-dart">class Money {
  final double amount;
  final String currency;

  Money({required this.amount, required this.currency}) {
    if (amount &lt; 0) {
      throw DomainException('Amount cannot be negative');
    }
    if (currency.isEmpty) {
      throw DomainException('Currency cannot be empty');
    }
  }

  Money add(Money other) {
    if (currency != other.currency) {
      throw DomainException('Cannot add different currencies');
    }
    return Money(amount: amount + other.amount, currency: currency);
  }

  @override
  String toString() =&gt; '$currency ${amount.toStringAsFixed(2)}';
}
</code></pre>
<p>Value Objects are immutable. You never modify a Value Object. Instead, you create a new one. <code>EmployeeId</code> and <code>Money</code> above both validate at construction. No invalid <code>EmployeeId</code> can ever exist anywhere in your domain. No negative <code>Money</code> can ever exist. The guard is built into the type itself.</p>
<h3 id="heading-entities">Entities</h3>
<p>An Entity is a domain object with an identity and rules about how its state can change.</p>
<pre><code class="language-dart">class Employee {
  final EmployeeId id;
  final String name;
  DateTime? clockInTime;
  DateTime? clockOutTime;

  Employee({
    required this.id,
    required this.name,
  });

  void clockIn(DateTime time) {
    if (clockInTime != null &amp;&amp; clockOutTime == null) {
      throw DomainException('Cannot clock in: already clocked in');
    }
    clockInTime = time;
    clockOutTime = null;
  }

  void clockOut(DateTime time) {
    if (clockInTime == null) {
      throw DomainException('Cannot clock out: not clocked in');
    }
    if (time.isBefore(clockInTime!)) {
      throw DomainException('Clock out time cannot be before clock in time');
    }
    clockOutTime = time;
  }

  bool get isClockedIn =&gt; clockInTime != null &amp;&amp; clockOutTime == null;

  Duration? get hoursWorked {
    if (clockInTime == null || clockOutTime == null) return null;
    return clockOutTime!.difference(clockInTime!);
  }
}
</code></pre>
<p>The Entity owns its state transitions. <code>clockIn</code> and <code>clockOut</code> aren't just setters. They enforce business rules. An employee can't clock in twice without clocking out, just like they can't clock out before they've clocked in. These rules live on the Entity where they belong, not in a use case, widget, or repository.</p>
<h3 id="heading-dtos">DTOs</h3>
<p>A Data Transfer Object carries raw data from an external source: an API response, database row, or JSON payload. DTOs have no business rules or behavior. They exist only to transport data across a boundary.</p>
<pre><code class="language-dart">class EmployeeDTO {
  final String id;
  final String name;
  final String? clockInTime;
  final String? clockOutTime;

  const EmployeeDTO({
    required this.id,
    required this.name,
    this.clockInTime,
    this.clockOutTime,
  });

  factory EmployeeDTO.fromJson(Map&lt;String, dynamic&gt; json) {
    return EmployeeDTO(
      id: json['id'] as String,
      name: json['name'] as String,
      clockInTime: json['clock_in_time'] as String?,
      clockOutTime: json['clock_out_time'] as String?,
    );
  }

  Employee toDomain() {
    final employee = Employee(
      id: EmployeeId(id),
      name: name,
    );

    if (clockInTime != null) {
      employee.clockIn(DateTime.parse(clockInTime!));
    }
    if (clockOutTime != null) {
      employee.clockOut(DateTime.parse(clockOutTime!));
    }

    return employee;
  }
}
</code></pre>
<p>The DTO lives in the infrastructure layer. It knows about JSON, while the domain entity knows nothing about JSON. The <code>toDomain()</code> method converts raw data into a properly constructed domain object, firing the business rules along the way.</p>
<h2 id="heading-business-rules-and-where-they-live">Business Rules and Where They Live</h2>
<p>Every business rule in a modular feature lives in the domain layer. This isn't a preference. It's the architectural contract that makes the entire approach work.</p>
<p><strong>Value Objects</strong> enforce atomic, intrinsic rules. An email address must contain an @ symbol. A monetary amount can't be negative. An employee ID can't be empty. These rules are about the data itself, and they belong on the Value Object.</p>
<p><strong>Entities</strong> enforce stateful rules. An employee can't clock in twice. A payment can't be processed if it has already been refunded. A task can't be completed if its dependencies aren't complete. These rules involve the state of the entity changing over time. They belong on the Entity.</p>
<p><strong>Domain Services</strong> enforce rules that span multiple entities or require coordination. An employee can't approve their own leave request. A payment requires verification from both the payer and a fraud detection check. These rules involve multiple entities and don't naturally belong on any single one. They belong in a Domain Service.</p>
<p>The rule is simple and absolute: if something is a business rule, it lives in the domain layer. The application layer calls the domain. The infrastructure layer implements the domain's interfaces. The presentation layer reacts to the results. None of them define or modify business rules.</p>
<p>This boundary is what makes the system trustworthy. Business rules can't be bypassed by going directly to the database. They can't be skipped by calling a repository method directly from a widget. They're always enforced because the only way to change domain state is through the domain objects themselves.</p>
<h2 id="heading-exception-handling-and-safe-state-propagation">Exception Handling and Safe State Propagation</h2>
<p>When a business rule is violated in the domain layer, the domain throws a <code>DomainException</code>. This exception travels up through the application layer to the presentation layer where it's converted into UI state.</p>
<p>The key insight is that the exception doesn't crash the app. It's caught at a defined boundary and converted into a safe state that the UI can render.</p>
<p>Here's the complete flow:</p>
<pre><code class="language-dart">// domain layer — throws when rules are violated
class Employee {
  void clockIn(DateTime time) {
    if (clockInTime != null &amp;&amp; clockOutTime == null) {
      throw DomainException('Cannot clock in: already clocked in');
    }
    clockInTime = time;
  }
}
</code></pre>
<pre><code class="language-dart">// application layer — catches domain exceptions and returns Result
class ClockInUseCase {
  final EmployeeRepository _repository;

  ClockInUseCase(this._repository);

  Future&lt;Result&lt;Employee, AppException&gt;&gt; execute(
    String employeeId,
    DateTime time,
  ) async {
    try {
      final employee = await _repository.findById(EmployeeId(employeeId));

      if (employee == null) {
        return Result.failure(AppException.notFound('Employee not found'));
      }

      employee.clockIn(time);
      await _repository.save(employee);

      return Result.success(employee);
    } on DomainException catch (e) {
      return Result.failure(AppException.businessRule(e.message));
    } catch (e) {
      return Result.failure(AppException.unknown(e.toString()));
    }
  }
}
</code></pre>
<pre><code class="language-dart">// presentation layer — converts result into UI state
@riverpod
class EmployeeNotifier extends _$EmployeeNotifier {
  @override
  AsyncValue&lt;Employee?&gt; build() =&gt; const AsyncData(null);

  Future&lt;void&gt; clockIn(String employeeId) async {
    state = const AsyncLoading();

    final result = await ref
        .read(clockInUseCaseProvider)
        .execute(employeeId, DateTime.now());

    result.fold(
      onSuccess: (employee) =&gt; state = AsyncData(employee),
      onFailure: (error) =&gt; state = AsyncError(error, StackTrace.current),
    );
  }
}
</code></pre>
<pre><code class="language-dart">// widget — renders state, owns nothing
class EmployeeClockInWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(employeeNotifierProvider);

    return state.when(
      data: (employee) =&gt; employee != null
          ? ClockInSuccess(employee: employee)
          : const ClockInForm(),
      loading: () =&gt; const LoadingIndicator(),
      error: (error, _) =&gt; ErrorMessage(message: error.toString()),
    );
  }
}
</code></pre>
<p>The flow is linear and predictable:</p>
<ol>
<li><p>User input triggers a use case.</p>
</li>
<li><p>The use case calls the domain.</p>
</li>
<li><p>The domain enforces rules.</p>
</li>
<li><p>If a rule is violated, a DomainException is thrown.</p>
</li>
<li><p>The use case catches it and returns a failure Result.</p>
</li>
<li><p>The notifier converts the Result into AsyncError state.</p>
</li>
<li><p>The widget renders the error.</p>
</li>
</ol>
<p>At no point does the exception escape unhandled. And at no point does the widget touch business logic.</p>
<h2 id="heading-the-folder-structure">The Folder Structure</h2>
<p>With these concepts in place, the folder structure follows naturally:</p>
<pre><code class="language-plaintext">lib/
  shared/
    core/
      errors/
        domain_exception.dart
        app_exception.dart
      result/
        result.dart
      di/
        injection.dart
      utils/
        date_utils.dart

  features/
    employee/
      domain/
        entities/
          employee.dart
        value_objects/
          employee_id.dart
        repositories/
          employee_repository.dart
        services/
          attendance_domain_service.dart
        exceptions/
          employee_exceptions.dart

      application/
        use_cases/
          clock_in_usecase.dart
          clock_out_usecase.dart
          get_employee_usecase.dart

      infrastructure/
        datasources/
          employee_remote_datasource.dart
          employee_local_datasource.dart
        repositories/
          employee_repository_impl.dart
        dtos/
          employee_dto.dart

      presentation/
        notifier/
          employee_notifier.dart
          employee_notifier.g.dart
        state/
          employee_state.dart
        pages/
          employee_dashboard_page.dart
          clock_in_page.dart
        widgets/
          employee_card.dart
          clock_in_button.dart

    payment/
      domain/
        entities/
          payment.dart
        value_objects/
          money.dart
          payment_reference.dart
        repositories/
          payment_repository.dart
        services/
          payment_verification_service.dart

      application/
        use_cases/
          initiate_payment_usecase.dart
          verify_payment_usecase.dart
          refund_payment_usecase.dart

      infrastructure/
        datasources/
          payment_remote_datasource.dart
        repositories/
          payment_repository_impl.dart
        dtos/
          payment_dto.dart

      presentation/
        notifier/
          payment_notifier.dart
          payment_notifier.g.dart
        pages/
          payment_page.dart
          payment_confirmation_page.dart
        widgets/
          payment_summary_card.dart

  main.dart
</code></pre>
<p>The <code>shared/core</code> folder holds only what's genuinely shared across all features: base error types, the Result type, dependency injection configuration, and utilities that have no business logic. Everything else belongs inside a feature.</p>
<p>Each feature is a complete vertical slice. Every layer a feature needs lives inside that feature's folder. A developer working on the Employee feature never needs to navigate outside the <code>features/employee</code> directory to understand or modify the feature.</p>
<h2 id="heading-real-world-example-one-the-employee-feature">Real World Example One: The Employee Feature</h2>
<p>The Employee feature manages the complete lifecycle of employee attendance in a workforce management application. Clock-in, clock-out, shift management, and attendance records.</p>
<h3 id="heading-the-domain">The Domain</h3>
<pre><code class="language-dart">// value objects
class EmployeeId {
  final String value;

  EmployeeId(this.value) {
    if (value.isEmpty) throw DomainException('Employee ID cannot be empty');
  }
}

class ShiftDuration {
  final Duration duration;

  ShiftDuration(this.duration) {
    if (duration.isNegative) {
      throw DomainException('Shift duration cannot be negative');
    }
    if (duration.inHours &gt; 16) {
      throw DomainException('Shift duration cannot exceed 16 hours');
    }
  }
}
</code></pre>
<pre><code class="language-dart">// entity
class Employee {
  final EmployeeId id;
  final String name;
  final String teamId;
  DateTime? clockInTime;
  DateTime? clockOutTime;

  Employee({
    required this.id,
    required this.name,
    required this.teamId,
  });

  void clockIn(DateTime time) {
    if (isClockedIn) {
      throw DomainException('Employee is already clocked in');
    }
    clockInTime = time;
    clockOutTime = null;
  }

  void clockOut(DateTime time) {
    if (!isClockedIn) {
      throw DomainException('Employee is not clocked in');
    }
    if (time.isBefore(clockInTime!)) {
      throw DomainException('Clock out time cannot be before clock in time');
    }

    final shift = ShiftDuration(time.difference(clockInTime!));
    clockOutTime = time;
  }

  bool get isClockedIn =&gt; clockInTime != null &amp;&amp; clockOutTime == null;

  Duration? get currentShiftDuration {
    if (!isClockedIn) return null;
    return DateTime.now().difference(clockInTime!);
  }
}
</code></pre>
<pre><code class="language-dart">// repository interface — in the domain layer
abstract class EmployeeRepository {
  Future&lt;Employee?&gt; findById(EmployeeId id);
  Future&lt;List&lt;Employee&gt;&gt; findByTeam(String teamId);
  Future&lt;void&gt; save(Employee employee);
}
</code></pre>
<h3 id="heading-the-application-layer">The Application Layer</h3>
<pre><code class="language-dart">class ClockInUseCase {
  final EmployeeRepository _repository;

  ClockInUseCase(this._repository);

  Future&lt;Result&lt;Employee, AppException&gt;&gt; execute(String employeeId) async {
    try {
      final id = EmployeeId(employeeId);
      final employee = await _repository.findById(id);

      if (employee == null) {
        return Result.failure(
          AppException.notFound('Employee $employeeId not found'),
        );
      }

      employee.clockIn(DateTime.now());
      await _repository.save(employee);

      return Result.success(employee);
    } on DomainException catch (e) {
      return Result.failure(AppException.businessRule(e.message));
    } catch (e) {
      return Result.failure(AppException.unknown(e.toString()));
    }
  }
}

class ClockOutUseCase {
  final EmployeeRepository _repository;

  ClockOutUseCase(this._repository);

  Future&lt;Result&lt;Employee, AppException&gt;&gt; execute(String employeeId) async {
    try {
      final id = EmployeeId(employeeId);
      final employee = await _repository.findById(id);

      if (employee == null) {
        return Result.failure(
          AppException.notFound('Employee $employeeId not found'),
        );
      }

      employee.clockOut(DateTime.now());
      await _repository.save(employee);

      return Result.success(employee);
    } on DomainException catch (e) {
      return Result.failure(AppException.businessRule(e.message));
    } catch (e) {
      return Result.failure(AppException.unknown(e.toString()));
    }
  }
}
</code></pre>
<h3 id="heading-the-infrastructure-layer">The Infrastructure Layer</h3>
<pre><code class="language-dart">class EmployeeDTO {
  final String id;
  final String name;
  final String teamId;
  final String? clockInTime;
  final String? clockOutTime;

  const EmployeeDTO({
    required this.id,
    required this.name,
    required this.teamId,
    this.clockInTime,
    this.clockOutTime,
  });

  factory EmployeeDTO.fromJson(Map&lt;String, dynamic&gt; json) {
    return EmployeeDTO(
      id: json['id'] as String,
      name: json['name'] as String,
      teamId: json['team_id'] as String,
      clockInTime: json['clock_in_time'] as String?,
      clockOutTime: json['clock_out_time'] as String?,
    );
  }

  Employee toDomain() {
    final employee = Employee(
      id: EmployeeId(id),
      name: name,
      teamId: teamId,
    );

    if (clockInTime != null) {
      employee.clockIn(DateTime.parse(clockInTime!));
    }
    if (clockOutTime != null) {
      employee.clockOut(DateTime.parse(clockOutTime!));
    }

    return employee;
  }

  Map&lt;String, dynamic&gt; toJson() {
    return {
      'id': id,
      'name': name,
      'team_id': teamId,
      'clock_in_time': clockInTime,
      'clock_out_time': clockOutTime,
    };
  }
}

class EmployeeRepositoryImpl implements EmployeeRepository {
  final EmployeeRemoteDataSource _remote;

  EmployeeRepositoryImpl(this._remote);

  @override
  Future&lt;Employee?&gt; findById(EmployeeId id) async {
    final dto = await _remote.fetchEmployee(id.value);
    return dto?.toDomain();
  }

  @override
  Future&lt;List&lt;Employee&gt;&gt; findByTeam(String teamId) async {
    final dtos = await _remote.fetchTeamEmployees(teamId);
    return dtos.map((dto) =&gt; dto.toDomain()).toList();
  }

  @override
  Future&lt;void&gt; save(Employee employee) async {
    final dto = EmployeeDTO(
      id: employee.id.value,
      name: employee.name,
      teamId: employee.teamId,
      clockInTime: employee.clockInTime?.toIso8601String(),
      clockOutTime: employee.clockOutTime?.toIso8601String(),
    );
    await _remote.updateEmployee(dto);
  }
}
</code></pre>
<h3 id="heading-the-presentation-layer">The Presentation Layer</h3>
<pre><code class="language-csharp">@riverpod
class EmployeeNotifier extends _$EmployeeNotifier {
  @override
  AsyncValue&lt;Employee?&gt; build() =&gt; const AsyncData(null);

  Future&lt;void&gt; clockIn(String employeeId) async {
    state = const AsyncLoading();

    final result = await ref
        .read(clockInUseCaseProvider)
        .execute(employeeId);

    result.fold(
      onSuccess: (employee) =&gt; state = AsyncData(employee),
      onFailure: (error) =&gt; state = AsyncError(error, StackTrace.current),
    );
  }

  Future&lt;void&gt; clockOut(String employeeId) async {
    state = const AsyncLoading();

    final result = await ref
        .read(clockOutUseCaseProvider)
        .execute(employeeId);

    result.fold(
      onSuccess: (employee) =&gt; state = AsyncData(employee),
      onFailure: (error) =&gt; state = AsyncError(error, StackTrace.current),
    );
  }
}
</code></pre>
<pre><code class="language-csharp">class EmployeeDashboardPage extends ConsumerWidget {
  final String employeeId;

  const EmployeeDashboardPage({required this.employeeId, super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(employeeNotifierProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Employee Dashboard')),
      body: state.when(
        data: (employee) =&gt; employee != null
            ? EmployeeDashboardContent(
                employee: employee,
                onClockIn: () =&gt; ref
                    .read(employeeNotifierProvider.notifier)
                    .clockIn(employeeId),
                onClockOut: () =&gt; ref
                    .read(employeeNotifierProvider.notifier)
                    .clockOut(employeeId),
              )
            : const EmptyDashboard(),
        loading: () =&gt; const Center(child: CircularProgressIndicator()),
        error: (error, _) =&gt; ErrorView(message: error.toString()),
      ),
    );
  }
}
</code></pre>
<p>The widget knows nothing about clocking in, the EmployeeId Value Object, or the DomainException. It renders state and delegates actions to the notifier. Everything else happens in the layers below.</p>
<h2 id="heading-real-world-example-two-the-payment-feature">Real World Example Two: The Payment Feature</h2>
<p>The Payment feature handles the complete payment lifecycle: initiation, processing, verification, and failure handling. This is a domain with complex business rules that benefit enormously from being modeled explicitly.</p>
<h3 id="heading-the-domain">The Domain</h3>
<pre><code class="language-csharp">enum PaymentStatus {
  pending,
  processing,
  completed,
  failed,
  refunded,
}

class PaymentReference {
  final String value;

  PaymentReference(this.value) {
    if (value.isEmpty) {
      throw DomainException('Payment reference cannot be empty');
    }
    if (!RegExp(r'^[A-Z0-9]{8,16}$').hasMatch(value)) {
      throw DomainException('Invalid payment reference format');
    }
  }
}

class Payment {
  final PaymentReference reference;
  final Money amount;
  final String payerId;
  final String recipientId;
  PaymentStatus status;
  String? failureReason;
  DateTime? processedAt;

  Payment({
    required this.reference,
    required this.amount,
    required this.payerId,
    required this.recipientId,
    this.status = PaymentStatus.pending,
  });

  void startProcessing() {
    if (status != PaymentStatus.pending) {
      throw DomainException(
        'Cannot process payment: current status is ${status.name}',
      );
    }
    status = PaymentStatus.processing;
  }

  void complete() {
    if (status != PaymentStatus.processing) {
      throw DomainException(
        'Cannot complete payment: current status is ${status.name}',
      );
    }
    status = PaymentStatus.completed;
    processedAt = DateTime.now();
  }

  void fail(String reason) {
    if (status != PaymentStatus.processing) {
      throw DomainException(
        'Cannot fail payment: current status is ${status.name}',
      );
    }
    status = PaymentStatus.failed;
    failureReason = reason;
  }

  void refund() {
    if (status != PaymentStatus.completed) {
      throw DomainException(
        'Cannot refund payment: only completed payments can be refunded',
      );
    }
    status = PaymentStatus.refunded;
  }

  bool get canBeRefunded =&gt; status == PaymentStatus.completed;
  bool get isTerminal =&gt;
      status == PaymentStatus.completed ||
      status == PaymentStatus.failed ||
      status == PaymentStatus.refunded;
}
</code></pre>
<p>The Payment entity enforces every valid status transition. A payment that's already processing can't start processing again. A payment that hasn't completed can't be refunded. And a payment that has failed can't be completed.</p>
<p>These rules are encoded into the entity and enforced at every state change.</p>
<h3 id="heading-the-application-layer">The Application Layer</h3>
<pre><code class="language-csharp">class InitiatePaymentUseCase {
  final PaymentRepository _repository;

  InitiatePaymentUseCase(this._repository);

  Future&lt;Result&lt;Payment, AppException&gt;&gt; execute({
    required String reference,
    required double amount,
    required String currency,
    required String payerId,
    required String recipientId,
  }) async {
    try {
      final payment = Payment(
        reference: PaymentReference(reference),
        amount: Money(amount: amount, currency: currency),
        payerId: payerId,
        recipientId: recipientId,
      );

      payment.startProcessing();
      await _repository.save(payment);

      return Result.success(payment);
    } on DomainException catch (e) {
      return Result.failure(AppException.businessRule(e.message));
    } catch (e) {
      return Result.failure(AppException.unknown(e.toString()));
    }
  }
}

class VerifyPaymentUseCase {
  final PaymentRepository _repository;
  final PaymentVerificationService _verificationService;

  VerifyPaymentUseCase(this._repository, this._verificationService);

  Future&lt;Result&lt;Payment, AppException&gt;&gt; execute(String reference) async {
    try {
      final ref = PaymentReference(reference);
      final payment = await _repository.findByReference(ref);

      if (payment == null) {
        return Result.failure(
          AppException.notFound('Payment $reference not found'),
        );
      }

      final isVerified = await _verificationService.verify(payment);

      if (isVerified) {
        payment.complete();
      } else {
        payment.fail('Verification failed');
      }

      await _repository.save(payment);
      return Result.success(payment);
    } on DomainException catch (e) {
      return Result.failure(AppException.businessRule(e.message));
    } catch (e) {
      return Result.failure(AppException.unknown(e.toString()));
    }
  }
}
</code></pre>
<h3 id="heading-the-presentation-layer">The Presentation Layer</h3>
<pre><code class="language-csharp">@riverpod
class PaymentNotifier extends _$PaymentNotifier {
  @override
  AsyncValue&lt;Payment?&gt; build() =&gt; const AsyncData(null);

  Future&lt;void&gt; initiatePayment({
    required String reference,
    required double amount,
    required String currency,
    required String payerId,
    required String recipientId,
  }) async {
    state = const AsyncLoading();

    final result = await ref.read(initiatePaymentUseCaseProvider).execute(
          reference: reference,
          amount: amount,
          currency: currency,
          payerId: payerId,
          recipientId: recipientId,
        );

    result.fold(
      onSuccess: (payment) =&gt; state = AsyncData(payment),
      onFailure: (error) =&gt; state = AsyncError(error, StackTrace.current),
    );
  }

  Future&lt;void&gt; verifyPayment(String reference) async {
    state = const AsyncLoading();

    final result = await ref
        .read(verifyPaymentUseCaseProvider)
        .execute(reference);

    result.fold(
      onSuccess: (payment) =&gt; state = AsyncData(payment),
      onFailure: (error) =&gt; state = AsyncError(error, StackTrace.current),
    );
  }
}
</code></pre>
<pre><code class="language-csharp">class PaymentPage extends ConsumerWidget {
  const PaymentPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(paymentNotifierProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Payment')),
      body: state.when(
        data: (payment) {
          if (payment == null) return const PaymentForm();
          return PaymentStatusView(payment: payment);
        },
        loading: () =&gt; const Center(child: CircularProgressIndicator()),
        error: (error, _) =&gt; PaymentErrorView(message: error.toString()),
      ),
    );
  }
}
</code></pre>
<h2 id="heading-cross-feature-communication">Cross-Feature Communication</h2>
<p>Self-contained features inevitably need to communicate. The Employee feature might need to check if a user is authenticated. The Payment feature might need to notify the Employee feature about a salary disbursement.</p>
<p>The rules for cross-feature communication preserve the self-containment of each feature:</p>
<h3 id="heading-1-features-never-import-directly-from-each-others-internal-layers">1. Features never import directly from each other's internal layers.</h3>
<p>The Employee feature should never import from <code>features/payment/domain/entities/payment.dart</code>. Direct imports between features create tight coupling that defeats the purpose of modularization.</p>
<h3 id="heading-2-shared-domain-concepts-live-in-sharedcore">2. Shared domain concepts live in <code>shared/core</code>.</h3>
<p>If both the Employee feature and the Payment feature need a concept like a <code>UserId</code> or a <code>Money</code> value object, that concept lives in <code>shared/core/domain</code> and both features import from there.</p>
<h3 id="heading-3-cross-feature-communication-happens-through-defined-interfaces">3. Cross-feature communication happens through defined interfaces.</h3>
<p>If the Payment feature needs to know about an employee to process a salary, it depends on an <code>EmployeeService</code> interface defined in <code>shared/core</code>. The Employee feature provides the implementation. The Payment feature never knows which feature provided it.</p>
<h3 id="heading-4-events-and-domain-notifications-use-a-shared-event-bus">4. Events and domain notifications use a shared event bus.</h3>
<p>When a payment completes, the Payment feature publishes a <code>PaymentCompleted</code> domain event. Any feature that needs to react to payment completion subscribes to that event. The Payment feature doesn't know who's listening. The listening features don't import from the Payment feature directly.</p>
<h2 id="heading-patterns-that-enhance-modularization">Patterns That Enhance Modularization</h2>
<p>Certain design patterns work particularly well within a modularized feature structure.</p>
<p><strong>The Prototype Pattern</strong> is useful for creating employee templates in the Employee feature. An organization might have standard employee profiles for different roles. Cloning a template employee creates a new instance with the same configuration, allowing business rules to fire during the clone's state transitions rather than being bypassed.</p>
<pre><code class="language-csharp">class Employee {
  Employee clone() {
    return Employee(
      id: EmployeeId('${id.value}_copy_${DateTime.now().millisecondsSinceEpoch}'),
      name: name,
      teamId: teamId,
    );
  }
}
</code></pre>
<p><strong>The Singleton Pattern</strong> applies to genuinely unique domain objects. The currently authenticated user is a Singleton. The current session is a Singleton. These live in <code>shared/core</code> because they are cross-feature concerns.</p>
<p><strong>The Repository Pattern</strong> is the backbone of infrastructure abstraction in every feature. Domain layers define the interface. Infrastructure layers provide the implementation. Application layers use the interface without knowing which implementation is behind it.</p>
<p><strong>The Observer Pattern,</strong> through domain events, enables cross-feature communication without tight coupling. Each feature publishes events when significant domain state changes. Other features subscribe to those events through a shared event bus.</p>
<h2 id="heading-scaling-to-large-teams">Scaling to Large Teams</h2>
<p>Feature Modularization becomes even more valuable as teams grow. At ten engineers working on different features simultaneously, the self-contained nature of each feature prevents constant merge conflicts. Engineers working on the Employee feature and engineers working on the Payment feature are almost never touching the same files.</p>
<p>At thirty engineers, features can become separate Dart packages. The Employee feature becomes <code>packages/employee</code>. The Payment feature becomes <code>packages/payment</code>. They depend on a shared <code>packages/core</code> package. Each package has its own <code>pubspec.yaml</code>, its own tests, and can be independently versioned. This is the next level of modularization and it follows naturally from the feature-first structure.</p>
<p>At fifty engineers, teams can own entire features. The Employee team owns <code>packages/employee</code>. The Payment team owns <code>packages/payment</code>. The contracts between features, defined through interfaces in <code>packages/core</code>, become the API boundaries between teams. A team can release a new version of their feature package independently, and other teams update to that version when they're ready.</p>
<p>The folder structure you start with today is the same structure that scales to a package-based monorepo at fifty engineers. The concepts don't change. The boundaries just become more explicit.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Feature Modularization isn't a folder structure trick. It's a strategic decision about how to organize software so that it can grow without degrading.</p>
<p>It takes Clean Architecture's layering rules and DDD's domain modeling vocabulary and applies them at the feature level. Each feature owns its domain, business rules, data access, and UI. Nothing leaks out or bleeds in. Each feature can be understood, modified, and tested without understanding the entire application.</p>
<p>The Employee feature enforces attendance rules in its entity. The Payment feature enforces transaction state transitions in its entity. Both features use Value Objects to prevent invalid data from ever entering the domain. Both features use the same Result pattern to propagate failures safely from the domain through the application layer to the presentation layer.</p>
<p>When a new requirement arrives for the Employee feature, you open the employee folder and make the change. The Payment feature is untouched. When a new payment method is added to the Payment feature, you open the payment folder and make the change. The Employee feature is untouched.</p>
<p>This is what self-contained actually means. Not just a folder with a name. A complete vertical slice of your application that owns everything it needs and shares nothing it should not.</p>
<p>That is Feature Modularization. And that's how Flutter apps scale from one engineer to fifty without the codebase becoming a liability.</p>
<p>Happy Coding!!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Understanding the CAP Theorem: Consistency, Availability, and Partition Tolerance in System Design ]]>
                </title>
                <description>
                    <![CDATA[ In 1999, Eric Brewer made a claim that would shape how distributed systems would be designed for decades. He proposed that any distributed data store can only guarantee two of three properties simulta ]]>
                </description>
                <link>https://www.freecodecamp.org/news/understanding-the-cap-theorem-consistency-availability-and-partition-tolerance-in-system-design/</link>
                <guid isPermaLink="false">6a96f19b6bef806c586df810</guid>
                
                    <category>
                        <![CDATA[ CAP-Theorem ]]>
                    </category>
                
                    <category>
                        <![CDATA[ System Design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Architecture Design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ systemdesign ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Tue, 01 Sep 2026 15:39:07 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/9a2d1ff6-84d7-42aa-8654-15df15ad52f8.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In 1999, Eric Brewer made a claim that would shape how distributed systems would be designed for decades. He proposed that any distributed data store can only guarantee two of three properties simultaneously: Consistency, Availability, and Partition Tolerance.</p>
<p>Two years later, Seth Gilbert and Nancy Lynch formally proved it. It became known as the CAP Theorem.</p>
<p>Every distributed database, cloud service, and system that stores data across multiple machines makes a choice about these three properties. Understanding what those properties actually mean, why you can't have all three at the same time, and what the choice looks like in practice is fundamental knowledge for any engineer who builds or operates systems at scale.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-three-properties">The Three Properties</a></p>
</li>
<li><p><a href="#heading-why-you-cant-have-all-three">Why You Can't Have All Three</a></p>
</li>
<li><p><a href="#heading-the-real-choice-cp-or-ap">The Real Choice: CP or AP</a></p>
</li>
<li><p><a href="#heading-ca-systems-the-special-case">CA Systems: The Special Case</a></p>
</li>
<li><p><a href="#heading-real-systems-and-their-choices">Real Systems and Their Choices</a></p>
</li>
<li><p><a href="#heading-the-nuance-cap-doesnt-capture">The Nuance CAP Doesn't Capture</a></p>
</li>
<li><p><a href="#heading-how-to-think-about-this-when-designing-systems">How to Think About This When Designing Systems</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before reading this article you should be comfortable with:</p>
<ul>
<li><p>What a database is and the basic concept of reading and writing data</p>
</li>
<li><p>What it means for data to be stored on multiple machines (replication at a conceptual level)</p>
</li>
<li><p>Basic networking concepts: what a network request is and what it means for two machines to communicate</p>
</li>
</ul>
<p>You don't need experience with distributed systems. This article builds the concepts from first principles.</p>
<h2 id="heading-the-three-properties">The Three Properties</h2>
<h3 id="heading-consistency">Consistency</h3>
<p>In the context of CAP, Consistency has a specific technical meaning that's different from how the word is used in everyday conversation.</p>
<p>Consistency in CAP means that every read receives the most recent write or an error. Not an old value. Not a stale value. Either the latest data or an explicit failure.</p>
<p>If you write a value to a consistent distributed system and then immediately read it from any node in that system, you get the value you just wrote. Every node in the cluster reflects the same state at any given moment.</p>
<p>This is called strong consistency or linearizability. It's a strict guarantee. If node A has data that node B doesn't yet have, a consistent system will either wait until node B is updated before responding to reads, or refuse to respond until consistency is restored.</p>
<p>The word consistent in CAP doesn't mean the same thing as the C in ACID (Atomicity, Consistency, Isolation, Durability). ACID consistency refers to data integrity constraints. CAP consistency refers to all nodes seeing the same data at the same time.</p>
<h3 id="heading-availability">Availability</h3>
<p>Availability means that every request receives a response. Not necessarily the latest data, but a response. The system never refuses a request. It never returns an error saying it can't handle the query right now.</p>
<p>An available system keeps responding even when some of its nodes are failing or unreachable. It prioritizes uptime over data currency. If node A can't reach node B to get the latest write, an available system will respond with whatever data it has, even if that data is slightly out of date.</p>
<p>From the user's perspective, the system is always on. Requests always get answers.</p>
<h3 id="heading-partition-tolerance">Partition Tolerance</h3>
<p>A network partition occurs when the communication link between nodes in a distributed system breaks. Node A can't reach node B. They're both running and healthy, but they can't talk to each other.</p>
<p>Partition Tolerance means the system continues to operate despite network partitions. If two nodes can't communicate, the system keeps working rather than shutting down entirely.</p>
<p>This is the property that makes CAP interesting. Network partitions aren't theoretical. They happen in production systems regularly. Network cables fail. Routers drop packets. Data centers lose connectivity. Cloud providers have outages that isolate regions from each other. Any system running across multiple machines must deal with the reality that those machines will sometimes lose the ability to communicate.</p>
<h2 id="heading-why-you-cant-have-all-three">Why You Can't Have All Three</h2>
<p>Here's the scenario that proves the theorem.</p>
<p>You have a distributed database with two nodes: Node A and Node B. They replicate data between each other. A network partition occurs. Node A and Node B can no longer communicate.</p>
<p>A write comes in to Node A. Node A processes the write and stores the new value. Node B still has the old value. They're now out of sync and they can't communicate to fix it.</p>
<p>A read comes in to Node B.</p>
<p><strong>If you choose Consistency:</strong> Node B knows it can't reach Node A. It knows its data might be stale. To guarantee that every read returns the most recent write, Node B must refuse to respond until it can sync with Node A. It returns an error. The system is consistent but not available during the partition.</p>
<p><strong>If you choose Availability:</strong> Node B responds with the data it has, even though that data might be stale. The system keeps responding but the data might not reflect the latest write to Node A. The system is available but not consistent during the partition.</p>
<p>There is no third option. During a network partition, a distributed system must choose between returning potentially stale data (available, not consistent) or refusing to respond (consistent, not available).</p>
<p>Partition Tolerance isn't really a choice in this sense. If your system runs across multiple machines connected by a network, partitions will happen. You can either handle them (be partition tolerant) or not handle them, which means your system simply stops working when a partition occurs.</p>
<p>Most real systems can't afford to simply stop working. Partition Tolerance is therefore effectively mandatory for any distributed system that needs to remain operational.</p>
<p>This is why the real choice in distributed systems is between Consistency and Availability during a partition, not a free choice among all three.</p>
<h2 id="heading-the-real-choice-cp-or-ap">The Real Choice: CP or AP</h2>
<h3 id="heading-cp-systems-consistency-over-availability">CP Systems: Consistency over Availability</h3>
<p>A CP system chooses to be consistent during a partition at the cost of availability. When nodes can't communicate, the system refuses to respond rather than risk returning stale data.</p>
<p>This is the right choice when correctness is more important than uptime. This is key for financial systems, inventory management, or any domain where serving wrong data is worse than serving no data.</p>
<p>Imagine a payment system. A customer initiates a transfer. The network partitions. The destination node can't reach the source node to confirm the transfer completed. A CP system refuses to show the updated balance until it can confirm the state across all nodes. The user might see a timeout or an error. But they won't see a balance that doesn't reflect reality.</p>
<p>The trade-off is real. During the partition, some requests fail. Users experience errors. But the data they eventually see is correct.</p>
<h3 id="heading-ap-systems-availability-over-consistency">AP Systems: Availability over Consistency</h3>
<p>An AP system chooses to remain available during a partition at the cost of consistency. When nodes can't communicate, the system keeps responding with whatever data it has, even if that data is stale.</p>
<p>This is the right choice when uptime is more important than having the absolute latest data. This is important for social media feeds, product catalogues, user profile reads, or any domain where a slightly stale response is acceptable.</p>
<p>Imagine a social media platform. During a network partition, you view your feed. The AP system serves you content from the nearest available node even if that node hasn't received the last few minutes of posts from across the network. You might miss three posts temporarily. When the partition heals and nodes resynchronize, your feed catches up. No data is lost. You just saw slightly stale data for a short window.</p>
<p>The trade-off is also real. Different users might see different states of the data at the same time. Data written during a partition might create conflicts that need to be resolved when the partition heals. The system must have a strategy for handling these conflicts.</p>
<h4 id="heading-systems-that-lean-ap">Systems that lean AP:</h4>
<p>Cassandra, CouchDB, and DynamoDB are examples of systems that lean toward availability. They prioritize uptime and use eventual consistency: a guarantee that if no new writes occur, all nodes will eventually converge to the same value. Eventually is the key word. Not immediately. Not during the partition. But eventually.</p>
<p>Amazon DynamoDB's design philosophy explicitly acknowledges this trade-off. Amazon's shopping cart is a famous example: it's better to let a user add items to their cart (even if the cart state is slightly inconsistent across nodes) than to refuse the add operation because nodes can't reach each other.</p>
<h2 id="heading-ca-systems-the-special-case">CA Systems: The Special Case</h2>
<p>You might wonder about a system that chooses Consistency and Availability but not Partition Tolerance. Such a system would need to guarantee that it never experiences a network partition.</p>
<p>The only way to guarantee no network partitions is to run on a single machine. A single machine can't partition from itself. Traditional relational databases running on a single server, like a standalone PostgreSQL instance, are effectively CA. They're consistent (all reads return the latest data) and available (they keep responding) because there are no network partitions to worry about.</p>
<p>But a single-machine database isn't a distributed system. It can't scale horizontally. It has a single point of failure. The moment you replicate that database across multiple machines, you have a distributed system and network partitions become a reality you must handle.</p>
<p>This is why the CAP Theorem is specifically about distributed systems. The CA category is largely theoretical for systems that need to scale beyond a single machine.</p>
<h2 id="heading-real-systems-and-their-choices">Real Systems and Their Choices</h2>
<p>Real distributed systems don't simply label themselves CP or AP and call it done. The reality is more nuanced. Most systems make different choices for different operations, configure their behavior through tunable consistency levels, and optimize for specific use cases.</p>
<h3 id="heading-apache-cassandra">Apache Cassandra</h3>
<p>Cassandra is fundamentally AP. It prioritizes availability and uses eventual consistency as its default model. But Cassandra gives developers control through consistency levels on every read and write operation.</p>
<p>A write with consistency level ALL requires all replicas to acknowledge the write before it succeeds. This is strong consistency at the cost of availability. A write fails if any replica is unreachable.</p>
<p>A write with consistency level ONE requires only one replica to acknowledge. Highly available, eventually consistent.</p>
<p>A write with consistency level QUORUM requires a majority of replicas to acknowledge. This is the most common production choice: a balance between consistency and availability.</p>
<p>Cassandra's design acknowledges that different operations in the same system may have different consistency requirements. A financial transaction might use QUORUM. An analytics event write might use ONE.</p>
<h3 id="heading-amazon-dynamodb">Amazon DynamoDB</h3>
<p>DynamoDB offers both eventually consistent reads (AP) and strongly consistent reads (CP) on every read operation. The developer chooses per request.</p>
<p>Eventually consistent reads are cheaper and faster. Strongly consistent reads cost more and take longer but guarantee the latest data.</p>
<h3 id="heading-google-spanner">Google Spanner</h3>
<p>Spanner is an interesting case. Google built it to provide strong consistency across a globally distributed system. It achieves this through a combination of atomic clocks, GPS receivers, and a carefully designed protocol called TrueTime.</p>
<p>Spanner essentially narrows the window of uncertainty about the ordering of events across data centers to the point where strong consistency becomes practical even across continents.</p>
<p>Spanner challenges the conventional wisdom that CP systems must sacrifice significant availability. But it does so through extraordinary infrastructure investment rather than by violating the CAP theorem.</p>
<h3 id="heading-zookeeper">ZooKeeper</h3>
<p>ZooKeeper is explicitly CP. It's designed for coordination: distributed locks, leader election, and configuration management. In these use cases, returning stale data would be actively harmful. If two nodes both believe they hold a distributed lock because they read stale state, you have a serious problem. ZooKeeper accepts reduced availability to guarantee that every read reflects the latest committed write.</p>
<h2 id="heading-the-nuance-cap-doesnt-capture">The Nuance CAP Doesn't Capture</h2>
<p>The CAP Theorem is a useful mental model, but it has known limitations that the distributed systems community has spent years articulating.</p>
<h3 id="heading-partitions-are-rare-latency-is-constant">Partitions are Rare. Latency is Constant.</h3>
<p>Network partitions happen, but they're relatively infrequent in well-operated systems. What happens all the time, in every request, in every system, is latency: the time it takes for data to replicate from one node to another. The time it takes for a consensus protocol to complete. The time it takes to confirm that all replicas have received a write.</p>
<p>CAP treats the choice between consistency and availability as a binary decision made only during a partition. But in practice, engineers are making trade-offs between consistency and latency on every operation, partition or not.</p>
<h3 id="heading-not-all-inconsistency-is-the-same">Not All Inconsistency is the Same.</h3>
<p>CAP treats consistency as binary: either every read returns the most recent write or it doesn't. But there's a spectrum between strong consistency and complete chaos. Eventual consistency, monotonic read consistency, read-your-writes consistency, and causal consistency: these are all weaker than strong consistency but stronger than no consistency guarantee at all. The CAP model doesn't distinguish between them.</p>
<h2 id="heading-how-to-think-about-this-when-designing-systems">How to Think About This When Designing Systems</h2>
<p>When you're making architectural decisions about data storage in a distributed system, the CAP Theorem gives you a framework for asking the right questions rather than a formula that gives you the right answer.</p>
<h3 id="heading-1-start-with-the-failure-mode-that-matters">1. Start with the failure mode that matters.</h3>
<p>What happens if two nodes in your system disagree about the state of the data? Is it worse to show the user wrong data or to show them an error?</p>
<p>For a bank account balance, showing wrong data is far worse than showing an error. For a social media like count, being off by a few for a few seconds is completely acceptable.</p>
<h3 id="heading-2-think-about-what-stale-means-for-your-data">2. Think about what "stale" means for your data.</h3>
<p>How quickly does your data change? If you're storing product descriptions that change once a month, serving data that's five seconds old during a partition is completely harmless. If you're storing stock prices that change hundreds of times per second, five seconds of staleness is a significant problem.</p>
<h3 id="heading-3-consider-the-frequency-and-duration-of-partitions-in-your-environment">3. Consider the frequency and duration of partitions in your environment.</h3>
<p>If your system runs in a single data center on a reliable network, partitions are rare and brief. If your system spans multiple geographic regions, partitions are more frequent and potentially longer. The less reliable your network, the more important your partition strategy becomes.</p>
<h3 id="heading-4-recognize-that-most-systems-need-both-just-for-different-operations">4. Recognize that most systems need both, just for different operations.</h3>
<p>A well-designed system often uses strong consistency for writes that must be correct (financial transactions, inventory deductions, or user authentication) and eventual consistency for reads where slight staleness is acceptable (feed generation, analytics, or non-critical reads). The choice isn't always system-wide. It can be operation-specific.</p>
<h3 id="heading-5-accept-that-the-trade-off-is-real-and-cant-be-engineered-away">5. Accept that the trade-off is real and can't be engineered away.</h3>
<p>There's no architectural trick that gives you strong consistency, perfect availability, and partition tolerance simultaneously. Engineers who believe they have found one have usually either not thought through the failure scenarios carefully or are operating in an environment where the constraints are gentler than they appear. The CAP Theorem is a mathematical proof. The constraints it describes are fundamental, not implementation details to be optimized away.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The CAP Theorem states that a distributed system can guarantee at most two of three properties: Consistency, Availability, and Partition Tolerance. Because network partitions are an unavoidable reality in any system running across multiple machines, the practical choice is between Consistency and Availability during a partition.</p>
<p>CP systems choose correctness over uptime. During a partition, they refuse to respond rather than risk returning stale data. They're the right choice when serving wrong data causes serious harm.</p>
<p>AP systems choose uptime over correctness. During a partition, they keep responding with whatever data they have, accepting that some responses may be stale. They're the right choice when availability matters more than having the absolute latest data, and when temporary inconsistency can be resolved after the partition heals.</p>
<p>Most real distributed systems don't make a single system-wide choice. They offer tunable consistency levels, make different choices for different operations, and optimize for their specific use cases. Cassandra, DynamoDB, and Spanner all sit on different points of the spectrum and all made deliberate engineering decisions about where to be.</p>
<p>The value of understanding CAP isn't that it gives you the answer. It's that it gives you the right questions. What does consistency mean for this data? What happens if nodes disagree? What's the cost of an error versus the cost of stale data? What does my system need to do during a partition?</p>
<p>Answering those questions honestly, for your specific use case, is how you make the right architectural decision for the system you're actually building.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Bloom Filters Explained: The Probabilistic Data Structure Powering Instagram, Google, and High-Scale Systems ]]>
                </title>
                <description>
                    <![CDATA[ Instagram has over 500 million registered usernames. When a new user tries to register, the platform needs to answer one question almost instantly: has this username already been taken? The naïve answ ]]>
                </description>
                <link>https://www.freecodecamp.org/news/bloom-filters-explained/</link>
                <guid isPermaLink="false">6a95f9a98577dd0ab849ab46</guid>
                
                    <category>
                        <![CDATA[ bloom filter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Bloom ]]>
                    </category>
                
                    <category>
                        <![CDATA[ System Design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ System Architecture ]]>
                    </category>
                
                    <category>
                        <![CDATA[ distributed system ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Mon, 31 Aug 2026 22:01:13 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/e3025afa-a736-4dd0-923b-e6ea8508f030.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Instagram has over 500 million registered usernames. When a new user tries to register, the platform needs to answer one question almost instantly: has this username already been taken?</p>
<p>The naïve answer is a database query. Pull the users table, search for the username, return whether it exists. This works fine at small scale. But at 500 million records, queried millions of times every day, it becomes a serious bottleneck. Even with indexing, the database is doing expensive work for every single registration attempt.</p>
<p>Luckily, there's a smarter approach. Before the database is ever touched, you ask a different system a much faster question. That system gives you one of two answers:</p>
<p><strong>Definitely not here:</strong> This is guaranteed. Zero exceptions. The username is available and you can skip the database entirely.</p>
<p><strong>Probably here:</strong> This is not guaranteed. The username might be taken, or this might be a false alarm. You need to confirm with the database.</p>
<p>That system is a Bloom Filter. It can't tell you with certainty that something exists. But it can tell you with absolute certainty that something does <strong>not</strong> exist. And in systems at scale, that one-sided guarantee eliminates the vast majority of expensive database queries.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-what-is-a-bloom-filter">What is a Bloom Filter?</a></p>
</li>
<li><p><a href="#heading-the-two-components-of-a-bloom-filter">The Two Components of a Bloom Filter</a></p>
</li>
<li><p><a href="#heading-how-adding-an-item-works">How Adding an Item Works</a></p>
</li>
<li><p><a href="#heading-how-checking-an-item-works">How Checking an Item Works</a></p>
</li>
<li><p><a href="#heading-false-positives-explained">False Positives Explained</a></p>
</li>
<li><p><a href="#heading-why-you-cant-delete-from-a-bloom-filter">Why You Can't Delete From a Bloom Filter</a></p>
</li>
<li><p><a href="#heading-the-false-positive-rate">The False Positive Rate</a></p>
</li>
<li><p><a href="#heading-implementation-in-dart">Implementation in Dart</a></p>
</li>
<li><p><a href="#heading-implementation-in-c">Implementation in C#</a></p>
</li>
<li><p><a href="#heading-where-bloom-filters-are-used-in-real-systems">Where Bloom Filters Are Used in Real Systems</a></p>
</li>
<li><p><a href="#heading-when-to-use-a-bloom-filter">When to Use a Bloom Filter</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-prerequisites">Prerequisites</h2>
<p>Before reading this article, you should be comfortable with:</p>
<ul>
<li><p>Basic data structures: arrays and how indexing works</p>
</li>
<li><p>What a hash function is at a conceptual level: a function that takes an input and produces a fixed-size output</p>
</li>
<li><p>Basic programming concepts in either Dart or C#</p>
</li>
</ul>
<p>You don't need to understand database internals or distributed systems deeply. Where these appear in this article, they serve only to illustrate why Bloom Filters matter in real engineering.</p>
<h2 id="heading-what-is-a-bloom-filter">What is a Bloom Filter?</h2>
<p>A Bloom Filter is a probabilistic data structure that represents a set without storing the actual values in that set.</p>
<p>The word probabilistic is the important one. Unlike a regular set or a database, a Bloom Filter doesn't give you definitive answers about membership. It gives you probabilistic answers. And the probability is deliberately asymmetric:</p>
<p>It will never tell you something is absent when it's actually present. This is called having no false negatives.</p>
<p>It might occasionally tell you something is present when it is actually absent. This is called a false positive. The rate at which this happens is small, controlled, and mathematically predictable.</p>
<p>This asymmetry is what makes Bloom Filters useful. The "definitely not here" answer is trustworthy. The "probably here" answer is a signal to go confirm elsewhere.</p>
<h2 id="heading-the-two-components-of-a-bloom-filter">The Two Components of a Bloom Filter</h2>
<p>A Bloom Filter is built from two things.</p>
<h4 id="heading-1-a-bit-array">1. A bit array</h4>
<p>A fixed-size array of bits, all initialized to zero. This is the entire storage of the filter. Not strings or objects, just bits. Zeros and ones.</p>
<pre><code class="language-plaintext">Position: 0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15
Value:     0  0  0  0  0  0  0  0  0  0  0  0  0  0  0  0
</code></pre>
<p>The size of this array is chosen based on how many items you expect to store and what false positive rate you can tolerate. A larger array means fewer false positives but more memory.</p>
<h4 id="heading-2-multiple-hash-functions">2. Multiple hash functions</h4>
<p>There are typically three to seven hash functions. Each one takes an input and produces a number that maps to a position in the bit array. Different hash functions produce different positions for the same input.</p>
<p>The hash functions must be fast, independent of each other, and distribute their outputs uniformly across the bit array. The quality of the hash functions directly affects the false positive rate.</p>
<h2 id="heading-how-adding-an-item-works">How Adding an Item Works</h2>
<p>To add the username "seyi_codes" to the filter, you run it through all three hash functions:</p>
<pre><code class="language-plaintext">hash1("seyi_codes") = 3
hash2("seyi_codes") = 7
hash3("seyi_codes") = 11
</code></pre>
<p>You set those three positions in the bit array to 1:</p>
<pre><code class="language-plaintext">Position: 0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15
Value:     0  0  0  1  0  0  0  1  0  0  0  1  0  0  0  0
                    ^           ^              ^
                  hash1       hash2          hash3
</code></pre>
<p>The username "seyi_codes" is now represented in the filter. But the string itself is stored nowhere. Only the fact that positions 3, 7, and 11 were set by something.</p>
<p>Now add a second username "aderonke":</p>
<pre><code class="language-plaintext">hash1("aderonke") = 1
hash2("aderonke") = 7
hash3("aderonke") = 13
</code></pre>
<pre><code class="language-plaintext">Position: 0  1  2  3  4  5  6  7  8  9  10 11 12 13 14 15
Value:     0  1  0  1  0  0  0  1  0  0  0  1  0  1  0  0
              ^     ^           ^              ^     ^
</code></pre>
<p>Position 7 was already set by "seyi_codes" and gets set again by "aderonke". This is normal. Bits can be shared across multiple items. This sharing is also the source of false positives. We'll talk a bit more about this below.</p>
<h2 id="heading-how-checking-an-item-works">How Checking an Item Works</h2>
<p>To check whether a username exists in the filter, you run it through the same hash functions and check whether all the resulting positions are set to 1.</p>
<h4 id="heading-checking-seyicodes-which-was-added">Checking "seyi_codes" which was added:</h4>
<pre><code class="language-plaintext">hash1("seyi_codes") = 3  → bit[3] = 1  ✓
hash2("seyi_codes") = 7  → bit[7] = 1  ✓
hash3("seyi_codes") = 11 → bit[11] = 1 ✓
</code></pre>
<p>All three positions are 1. The filter says "probably here." We confirm with the database, and see that the username is taken.</p>
<h4 id="heading-checking-johndoe-which-was-never-added">Checking "john_doe" which was never added:</h4>
<pre><code class="language-plaintext">hash1("john_doe") = 2  → bit[2] = 0  ✗
</code></pre>
<p>The very first hash returned a position that is still 0. We stop immediately. The filter says "definitely not here." We don't check the remaining hash functions. We don't touch the database. The username is available.</p>
<p>This early exit is critical to understanding why Bloom Filters are fast. The moment any hash function returns a 0 bit, the check is over. An item that was actually added would have set all its positions to 1. A 0 anywhere proves the item was never added.</p>
<h2 id="heading-false-positives-explained">False Positives Explained</h2>
<p>Here's where the probabilistic part becomes concrete.</p>
<p>Check the username "tiwa_codes" which was never added:</p>
<pre><code class="language-plaintext">hash1("tiwa_codes") = 3  → bit[3] = 1  ✓  (set by seyi_codes)
hash2("tiwa_codes") = 7  → bit[7] = 1  ✓  (set by seyi_codes and aderonke)
hash3("tiwa_codes") = 13 → bit[13] = 1 ✓  (set by aderonke)
</code></pre>
<p>All three positions are 1. The filter says "probably here." But "tiwa_codes" was never added. The bits at positions 3, 7, and 13 were set by other usernames. "tiwa_codes" happened to hash to positions that were already occupied.</p>
<p>This is a false positive. The filter is wrong. But it's wrong in the acceptable direction. It said "probably here" when the answer is actually "not here." We go to the database, confirm the username is actually available, and proceed.</p>
<p>The filter never makes the opposite mistake. It will never say "definitely not here" for something that was actually added, because adding an item sets all its positions to 1, and the check verifies all positions are 1.</p>
<h2 id="heading-why-you-cant-delete-from-a-bloom-filter">Why You Can't Delete From a Bloom Filter</h2>
<p>Deletion isn't possible in a standard Bloom Filter.</p>
<p>If you tried to delete "seyi_codes" by flipping its bits back to 0, you would flip position 7 back to 0. But position 7 is also used by "aderonke." Now "aderonke" would appear to be absent from the filter even though it was never removed.</p>
<p>This is a fundamental limitation. The filter doesn't track which item set which bit. Bits are shared. Removing one item's bits would corrupt the representation of other items.</p>
<p>Variations like the Counting Bloom Filter solve this by storing a count at each position instead of a single bit (and incrementing the count on add and decrementing on delete). But counting filters use significantly more memory.</p>
<h2 id="heading-the-false-positive-rate">The False Positive Rate</h2>
<p>The false positive rate isn't random. It's determined mathematically by three parameters:</p>
<p><strong>m</strong> is the size of the bit array. A larger array means more positions available, fewer collisions, lower false positive rate, and more memory consumed.</p>
<p><strong>n</strong> is the number of items added to the filter. As more items are added, more bits are set to 1. More bits set to 1 means more positions are already occupied by other items, which means higher probability of false positives.</p>
<p><strong>k</strong> is the number of hash functions. More hash functions means each item leaves a more specific fingerprint. Initially this reduces false positives. But as the array fills up, more hash functions means more positions to check, each of which has a higher probability of already being set.</p>
<p>For a filter with a 1% false positive rate representing 100 million items, you need approximately 958 million bits (about 120 megabytes). The actual usernames stored as strings would require several gigabytes. The filter achieves a 95% reduction in memory by storing fingerprints instead of values.</p>
<p>For most production systems, a false positive rate between 0.1% and 1% strikes the right balance. At 1%, 99% of "username available" queries never touch the database. The 1% that are false positives go to the database for confirmation and get resolved correctly there.</p>
<h2 id="heading-implementation-in-dart">Implementation in Dart</h2>
<p>This is an educational implementation showing the mechanics of how a Bloom Filter works. In production systems, Bloom Filters are built into the infrastructure layers (databases, caches, or CDNs) rather than implemented at the application level.</p>
<pre><code class="language-dart">import 'dart:typed_data';

class BloomFilter {
  final Uint8List _bitArray;
  final int _size;
  final int _hashCount;

  BloomFilter({required int size, required int hashCount})
      : _size = size,
        _hashCount = hashCount,
        _bitArray = Uint8List((size / 8).ceil());

  // set a bit at the given position
  void _setBit(int position) {
    final byteIndex = position ~/ 8;
    final bitIndex = position % 8;
    _bitArray[byteIndex] |= (1 &lt;&lt; bitIndex);
  }

  // check if a bit is set at the given position
  bool _getBit(int position) {
    final byteIndex = position ~/ 8;
    final bitIndex = position % 8;
    return (_bitArray[byteIndex] &amp; (1 &lt;&lt; bitIndex)) != 0;
  }

  // generate k hash positions for a given value
  List&lt;int&gt; _getHashPositions(String value) {
    final positions = &lt;int&gt;[];

    for (int i = 0; i &lt; _hashCount; i++) {
      int hash = 0;
      final input = '$i:$value'; 

      for (final char in input.codeUnits) {
        hash = (hash * 31 + char) &amp; 0x7FFFFFFF;
      }

      positions.add(hash % _size);
    }

    return positions;
  }

  // add an item to the filter
  void add(String value) {
    for (final position in _getHashPositions(value)) {
      _setBit(position);
    }
  }

  // check if an item might be in the filter
  // returns false: definitely NOT in the set
  // returns true: PROBABLY in the set (may be a false positive)
  bool mightContain(String value) {
    for (final position in _getHashPositions(value)) {
      if (!_getBit(position)) {
        return false; 
      }
    }
    return true; 
  }
}
</code></pre>
<p>Let's walk through the key decisions in this implementation.</p>
<p>First, <code>Uint8List</code> is used for the bit array instead of a <code>List&lt;bool&gt;</code>. A <code>List&lt;bool&gt;</code> in Dart allocates a full object per element. A <code>Uint8List</code> packs 8 bits per byte, which is how Bloom Filters achieve their memory efficiency. The <code>_setBit</code> and <code>_getBit</code> methods handle the byte and bit index arithmetic.</p>
<p><code>_getHashPositions</code> generates k different positions for a given value by seeding each hash function differently using the index i. Prepending <code>'$i:$value'</code> ensures each of the k hash functions produces a different result for the same input. The result is taken modulo <code>_size</code> to map the hash to a valid bit position in the filter.</p>
<p><code>mightContain</code> returns false the moment any position is not set. This is the early exit that makes checking fast. If all positions are set, it returns true, which means the item is probably in the set but might be a false positive.</p>
<p><strong>Using the Bloom Filter:</strong></p>
<pre><code class="language-dart">void main() {
  // filter sized for roughly 1000 items with low false positive rate
  final filter = BloomFilter(size: 10000, hashCount: 3);

  // add usernames to the filter
  final registeredUsernames = [
    'seyi_codes',
    'aderonke_dev',
    'inioluwa_tech',
    'tiwaloluwa',
    'flutter_ninja',
  ];

  for (final username in registeredUsernames) {
    filter.add(username);
    print('Added: $username');
  }

  print('');

  // check some usernames
  final usernamesToCheck = [
    'seyi_codes',      // was added — should return true
    'aderonke_dev',    // was added — should return true
    'john_doe',        // was not added — should return false
    'new_user_123',    // was not added — should return false
    'random_handle',   // was not added — should return false
  ];

  for (final username in usernamesToCheck) {
    final result = filter.mightContain(username);
    if (result) {
      print('$username: PROBABLY taken — confirm with database');
    } else {
      print('$username: DEFINITELY available — skip the database');
    }
  }
}
</code></pre>
<p>Output:</p>
<pre><code class="language-plaintext">Added: seyi_codes
Added: aderonke_dev
Added: inioluwa_tech
Added: tiwaloluwa
Added: flutter_ninja

seyi_codes: PROBABLY taken — confirm with database
aderonke_dev: PROBABLY taken — confirm with database
john_doe: DEFINITELY available — skip the database
new_user_123: DEFINITELY available — skip the database
random_handle: DEFINITELY available — skip the database
</code></pre>
<p>The items that were added return "probably taken" and would trigger a database confirmation. The items that were never added return "definitely available" and skip the database entirely.</p>
<h4 id="heading-simulating-the-false-positive-rate">Simulating the false positive rate:</h4>
<pre><code class="language-csharp">void measureFalsePositiveRate() {
  final filter = BloomFilter(size: 1000, hashCount: 3);
  final random = Random();

  // add 100 items
  final addedItems = &lt;String&gt;{};
  for (int i = 0; i &lt; 100; i++) {
    final item = 'user_$i';
    filter.add(item);
    addedItems.add(item);
  }

  // check 1000 items that were never added
  int falsePositives = 0;
  int totalChecks = 1000;

  for (int i = 100; i &lt; 100 + totalChecks; i++) {
    final item = 'user_$i'; 
    if (filter.mightContain(item)) {
      falsePositives++;
    }
  }

  final rate = (falsePositives / totalChecks * 100).toStringAsFixed(2);
  print('False positive rate: $rate% ($falsePositives out of $totalChecks)');
}
</code></pre>
<p>Running this demonstrates that false positives are real but controlled. Change the filter size and hash count and you can observe the false positive rate change accordingly.</p>
<h2 id="heading-implementation-in-c">Implementation in C#</h2>
<pre><code class="language-csharp">using System;
using System.Collections;
using System.Text;

public class BloomFilter
{
    private readonly BitArray _bitArray;
    private readonly int _size;
    private readonly int _hashCount;

    public BloomFilter(int size, int hashCount)
    {
        _size = size;
        _hashCount = hashCount;
        _bitArray = new BitArray(size);
    }

    private int[] GetHashPositions(string value)
    {
        var positions = new int[_hashCount];

        for (int i = 0; i &lt; _hashCount; i++)
        {
            var input = $"{i}:{value}";
            var bytes = Encoding.UTF8.GetBytes(input);

            int hash = 0;
            foreach (var b in bytes)
            {
                hash = (hash * 31 + b) &amp; 0x7FFFFFFF;
            }

            positions[i] = hash % _size;
        }

        return positions;
    }

    public void Add(string value)
    {
        foreach (var position in GetHashPositions(value))
        {
            _bitArray[position] = true;
        }
    }

    // false  = definitely NOT in the set
    // true   = PROBABLY in the set
    public bool MightContain(string value)
    {
        foreach (var position in GetHashPositions(value))
        {
            if (!_bitArray[position])
                return false;
        }
        return true;
    }
}
</code></pre>
<p>C# provides <code>BitArray</code> from <code>System.Collections</code> which handles the bit-level storage natively. The logic is identical to the Dart implementation: the same hashing approach, the same early exit on a zero bit, and the same probabilistic return value.</p>
<h4 id="heading-using-it-in-a-realistic-c-api-scenario">Using it in a realistic C# API scenario:</h4>
<pre><code class="language-csharp">public class UsernameService
{
    private readonly BloomFilter _bloomFilter;
    private readonly IUserRepository _userRepository;

    public UsernameService(IUserRepository userRepository)
    {
        _userRepository = userRepository;

        // sized for 10 million usernames, ~1% false positive rate
        _bloomFilter = new BloomFilter(size: 95_850_584, hashCount: 7);

        // populate the filter from existing usernames on startup
        LoadExistingUsernames();
    }

    private void LoadExistingUsernames()
    {
        // stream usernames from database to avoid loading all into memory
        foreach (var username in _userRepository.StreamAllUsernames())
        {
            _bloomFilter.Add(username);
        }
    }

    public async Task&lt;bool&gt; IsUsernameAvailableAsync(string username)
    {
        // check the filter first — O(k) where k is the number of hash functions
        if (!_bloomFilter.MightContain(username))
        {
            // definitely not taken — no database query needed
            return true;
        }

        // probably taken — confirm with the database
        // this handles both true positives and false positives
        var existingUser = await _userRepository.FindByUsernameAsync(username);
        return existingUser == null;
    }

    public async Task RegisterUsernameAsync(string username)
    {
        // add to the filter when a new username is registered
        _bloomFilter.Add(username);
        await _userRepository.CreateUserAsync(username);
    }
}
</code></pre>
<p>This is the pattern used in production systems. The Bloom Filter sits in front of the database. Most username availability checks never reach the database. The ones that do are either true positives (username is actually taken) or false positives (the filter was wrong, but the database confirms that the username is actually available).</p>
<p>The service adds new usernames to both the filter and the database on registration. On startup, it populates the filter from existing database records by streaming them rather than loading all into memory at once.</p>
<h2 id="heading-where-bloom-filters-are-used-in-real-systems">Where Bloom Filters Are Used in Real Systems</h2>
<h3 id="heading-instagram-and-twitter-username-availability">Instagram and Twitter: Username Availability</h3>
<p>The filter is checked before any database query. A "definitely not here" result skips the database entirely. A "probably here" result triggers a database confirmation. The vast majority of registration attempts get resolved without touching the database.</p>
<h3 id="heading-google-chrome-safe-browsing">Google Chrome: Safe Browsing</h3>
<p>Chrome ships with a Bloom Filter of known malicious URLs embedded directly in the browser. When you visit any URL, Chrome checks the local filter first. If the filter says "definitely not malicious," no network request is made. If it says "probably malicious," Chrome contacts Google's servers to confirm. Billions of URL checks happen locally with zero network latency.</p>
<h3 id="heading-apache-cassandra">Apache Cassandra</h3>
<p>Each SSTable (storage file on disk) has an associated Bloom Filter in memory. When querying for a key, Cassandra checks the filter for each SSTable first. A "definitely not here" result means Cassandra skips that SSTable entirely, avoiding an expensive disk read. This is one of the primary reasons Cassandra can sustain high read throughput on large datasets.</p>
<h3 id="heading-medium-recommendation-engine">Medium: Recommendation Engine</h3>
<p>Medium tracks which articles each user has already read using Bloom Filters. Before recommending an article, the filter is checked. A "definitely not read" result means the article is a candidate for recommendation. The filter prevents the same article from being recommended to a user who has already seen it, without storing the entire read history in a queryable database for every recommendation request.</p>
<h3 id="heading-akamai-cdn-cache-management">Akamai CDN: Cache Management</h3>
<p>Akamai uses Bloom Filters to identify one-hit wonders: content that's requested only once and isn't worth caching. When content is requested for the first time, it's added to the filter. If it appears in the filter on a subsequent request, it might be worth caching. Content that never appears twice in the filter isn't cached, saving expensive cache storage for content that actually benefits from it.</p>
<h2 id="heading-when-to-use-a-bloom-filter">When to Use a Bloom Filter</h2>
<p>Use a Bloom Filter when you need to check membership in a very large set and the cost of false positives is low.</p>
<p>The ideal scenario is one where a "definitely not here" answer lets you skip an expensive operation entirely, like a database query, network request, disk read, or cache miss. If the cost of that operation is significant and most checks return "not here," a Bloom Filter can eliminate the majority of those expensive operations.</p>
<p>You can also use it when memory efficiency matters. When the set is so large that storing the actual values would be prohibitively expensive in memory, a Bloom Filter represents that set in a fraction of the space.</p>
<p>And it's a good fit when false positives are acceptable and recoverable. If a false positive means one extra database query, that's acceptable. If a false positive means data loss or incorrect behavior, a Bloom Filter isn't the right tool.</p>
<h2 id="heading-when-not-to-use-it">When Not to Use It</h2>
<p>Don't use a Bloom Filter when you need exact membership testing with no false positives. A hash set or a database index gives you exact answers. If the cost of a false positive isn't acceptable, use an exact data structure.</p>
<p>Don't use it when you need to retrieve the actual stored values. A Bloom Filter stores no values. You can't get anything back from it except a yes or no answer.</p>
<p>It's also not a good idea when items need to be deletable. Standard Bloom Filters don't support deletion. If your use case requires removing items from the set, use a Counting Bloom Filter or a different data structure entirely.</p>
<p>Finally, don't use it for small sets. If the set is small enough that a hash set fits comfortably in memory, use a hash set. The complexity of a Bloom Filter isn't justified when an exact solution is available at the same memory cost.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>A Bloom Filter solves a specific problem with unusual elegance. When the set is enormous and checking membership against it directly is expensive, the filter acts as a fast, memory-efficient pre-check.</p>
<p>It tells you with absolute certainty when something is not in the set. That certainty is what makes it valuable. Every "definitely not here" answer is a database query avoided, a network request saved, and a disk read skipped.</p>
<p>The false positives are the price of that efficiency. A small, controlled, mathematically predictable rate of wrong answers in the "probably here" direction. Wrong in the direction that costs you one extra confirmation. Never wrong in the direction that causes you to miss something that was actually there.</p>
<p>Instagram uses this to protect a database with 500 million rows. Google uses this to check malicious URLs without a network request. Cassandra uses this to skip disk reads on terabytes of data. The pattern is the same in every case: an expensive operation sits behind a fast, probabilistic gate. The gate lets through only what needs to go through.</p>
<p>That's what a Bloom Filter does. And that's why it's one of the most quietly powerful data structures in modern systems engineering.</p>
<p>Understanding this definitely equips your architectural decision for large systems query and data set.</p>
<p>Happy Coding!!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Mobile Background Execution: iOS Background Modes, Android WorkManager, and Background Services in Dart ]]>
                </title>
                <description>
                    <![CDATA[ Every mobile developer eventually hits the same wall: the app works perfectly when the user is looking at it. But the moment they press the home button, everything stops. A sync that should have compl ]]>
                </description>
                <link>https://www.freecodecamp.org/news/mobile-background-execution-ios-background-modes-android-workmanager-and-background-services-in-dart/</link>
                <guid isPermaLink="false">6a8c831fe5597860d219d150</guid>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mobile ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ native ]]>
                    </category>
                
                    <category>
                        <![CDATA[ background jobs ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Mon, 24 Aug 2026 17:45:03 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/2e9b068d-5fec-4228-8a0f-1f34bcd1e5f1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every mobile developer eventually hits the same wall: the app works perfectly when the user is looking at it.</p>
<p>But the moment they press the home button, everything stops. A sync that should have completed in the background never ran. A notification that should have fired didn't. A file upload that started while the user was in the app failed silently the moment they switched away.</p>
<p>Background execution on mobile is one of the most misunderstood topics in the entire mobile engineering space. Most developers treat it like a simple problem: just keep the code running in the background. The platforms treat it like a resource management problem that directly affects battery life, performance, and the overall health of the device.</p>
<p>Understanding how iOS and Android actually think about background work, and then understanding how Flutter sits on top of both, is what separates engineers who fight the platform from engineers who work with it.</p>
<p>This article covers exactly that. You'll understand the native mechanisms on both platforms, the Flutter packages that bridge them, and when to reach for each approach.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-why-background-execution-is-hard">Why Background Execution is Hard</a></p>
</li>
<li><p><a href="#heading-how-flutter-runs-in-the-background">How Flutter Runs in the Background</a></p>
</li>
<li><p><a href="#heading-ios-background-execution">iOS Background Execution</a></p>
</li>
<li><p><a href="#heading-android-background-execution">Android Background Execution</a></p>
</li>
<li><p><a href="#heading-flutter-implementation">Flutter Implementation</a></p>
</li>
<li><p><a href="#heading-the-decision-framework">The Decision Framework</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h3 id="heading-prerequisites">Prerequisites</h3>
<p>This article is written for mobile engineers building or maintaining production applications on iOS, Android, or Flutter. Before reading, you should be comfortable with:</p>
<ul>
<li><p>The basic lifecycle of a mobile app: what happens when the app moves between foreground, background, and suspended states</p>
</li>
<li><p>General mobile development concepts: you have shipped or maintained at least one mobile application on any platform or framework</p>
</li>
<li><p>Writing mobile applications in any framework: native iOS, native Android, Flutter, React Native, or any other mobile development stack</p>
</li>
<li><p>Multithreading and concurrency in your programming language of choice: understanding how your language handles work that runs outside the main thread is the foundation of everything this article covers</p>
</li>
</ul>
<p>If you're completely new to mobile development or have never had to think about threads and concurrent execution, start with those fundamentals first and return to this article when you are ready to think about production-grade background work.</p>
<h2 id="heading-why-background-execution-is-hard">Why Background Execution is Hard</h2>
<p>When your app is in the foreground, the platform gives it essentially full access to CPU, network, and memory. The user is actively looking at the app. Battery drain is expected. Resource usage is justified.</p>
<p>The moment the app goes to the background, the calculus changes completely. There are potentially dozens of apps installed on the device. If every one of them ran freely in the background, the battery would drain in hours. The CPU would be constantly active. Memory would fill up. The device would become hot and slow.</p>
<p>Both iOS and Android made a decision early on that background execution is a privilege, not a right. Apps must earn the right to run in the background by declaring what they need and why. The platform then decides how, when, and for how long to grant that access.</p>
<p>iOS and Android approached this problem differently but have been converging toward the same model over time: declared, categorized, and constrained background work that the platform controls.</p>
<h2 id="heading-how-flutter-runs-in-the-background">How Flutter Runs in the Background</h2>
<p>Before looking at iOS and Android separately, you need to understand something fundamental about how Flutter works.</p>
<p>Flutter runs your Dart code in a single main isolate. This isolate is attached to the platform's main thread. It handles your UI, your business logic, everything. When the app goes to the background, this main isolate can be suspended at any time.</p>
<p>When background work needs to happen in Flutter, the platform doesn't wake your main isolate. It wakes a separate, independent Dart isolate specifically for background execution. This background isolate runs in complete isolation from the main isolate.</p>
<h3 id="heading-what-this-means-practically">What This Means Practically:</h3>
<p>The background isolate has no access to the widget tree. You can't call setState, update any UI, or use any widget or BuildContext. It's pure Dart execution with no UI layer.</p>
<p>The background isolate doesn't share memory with the main isolate. Any data you need in the background must be persisted to disk (shared preferences, local database, files) and read by the background isolate independently.</p>
<p>The background isolate must be a top-level function or a static method. It can't be an anonymous function or a method on a class instance. The platform needs to be able to call this function by name when waking the app.</p>
<p>Understanding this changes how you think about background work in Flutter. You aren't keeping your app running. You're registering a separate piece of Dart code that the platform can invoke on its own schedule, in its own isolated environment.</p>
<h2 id="heading-ios-background-execution">iOS Background Execution</h2>
<h3 id="heading-what-ios-does-to-your-app">What iOS Does to Your App</h3>
<p>iOS manages your app through a set of clearly defined states.</p>
<p>When the user presses the home button, your app moves from foreground to background. iOS gives you a very brief window, typically five to ten seconds, to finish whatever you were doing. After that, your app is suspended. Suspended means completely frozen in memory. No Dart code runs. No network requests go out. The app exists in RAM but is essentially paused.</p>
<p>iOS can kill suspended apps at any time if it needs memory. When the user returns to your app, iOS either resumes it from suspension (fast) or relaunches it from scratch (slow). If your app was killed while suspended, the user won't know. The app just relaunches normally.</p>
<p>To do anything meaningful in the background on iOS, you must declare your intentions. iOS has a specific list of background capabilities, and your app must request exactly the ones it needs. Apple reviews these declarations during App Store submission.</p>
<h3 id="heading-bgtaskscheduler">BGTaskScheduler</h3>
<p>BGTaskScheduler is the modern iOS API for scheduling background work. Introduced in iOS 13, it replaced older and less reliable approaches. It gives you two types of tasks.</p>
<p>BGAppRefreshTask is for short, periodic background work. Think of it as iOS giving your app a brief wake-up to check for new content and update its state. You get approximately 30 seconds. iOS decides when to run the task based on the user's usage patterns. If the user opens your app every morning at 8am, iOS learns this and tries to run your refresh task just before 8am so content is ready when they arrive.</p>
<p>BGProcessingTask is for longer, heavier work. Database migrations, large file processing, or ML model updates. You get several minutes. These tasks only run when the device is plugged in and ideally on WiFi. You get more time but no guarantees on when the task actually runs.</p>
<p>The rules iOS enforces are strict.</p>
<p>You must declare your task identifiers in Info.plist under <code>BGTaskSchedulerPermittedIdentifiers</code> before the app ships. If the identifier isn't declared there, the task will never run regardless of what your code does.</p>
<p>You must register your task handler before <code>applicationDidFinishLaunching</code> completes. This happens before Flutter even initializes. The workmanager package handles this automatically, but it's important to understand why.</p>
<p>Every task must call <code>setTaskCompleted</code> when it finishes. If you don't call this, iOS marks the task as failed and becomes increasingly reluctant to schedule future tasks.</p>
<p>You should always set an expiration handler. If iOS decides to kill your task early, it calls the expiration handler first, giving you a brief moment to clean up, save state, and mark the task as incomplete so it gets rescheduled.</p>
<h3 id="heading-ios-background-modes">iOS Background Modes</h3>
<p>Beyond BGTaskScheduler, iOS has specific background modes for certain categories of apps. These are declared in Info.plist and enable continuous background execution for very specific purposes.</p>
<p>Audio and AirPlay keeps your app running as long as it's playing audio. The user sees now-playing controls on the lock screen. Podcast apps, music apps, and navigation apps with voice guidance use this. iOS is generous with this mode because the user clearly intends the audio to continue.</p>
<p>Location Updates allows continuous GPS access even when backgrounded. There are two levels. Significant location changes uses cell tower data and is battery-friendly. It fires when the device moves significantly, roughly 500 meters. Continuous location updates give precise GPS but drain battery. Apple scrutinizes location background mode during review. You need a genuine, user-facing reason.</p>
<p>Background Fetch is a legacy mechanism where iOS periodically wakes your app for a short window to fetch content. Unlike BGAppRefreshTask, this uses the older API. Most new apps should prefer BGTaskScheduler.</p>
<p>Remote Notifications with the <code>content-available</code> flag allows your server to trigger a brief background wake. When your server sends a silent push notification, iOS wakes your app to process it. This is how many apps stay current without constant polling.</p>
<h3 id="heading-the-ios-reality">The iOS Reality</h3>
<p>iOS background execution is fundamentally about trust. Apple trusts your app with background time if you declare what you need, use it for the declared purpose, and respect the time limits.</p>
<p>Exceed your time limit and iOS terminates your app. Request background modes you don't actually need and App Store review will flag it. Use location in the background for purposes not evident to the user and you will face rejection.</p>
<p>The watchdog timer is real. iOS monitors background tasks actively. Tasks that run too long, use too much CPU, or behave unexpectedly get terminated. Build your background tasks to be fast, focused, and respectful of system resources.</p>
<h2 id="heading-android-background-execution">Android Background Execution</h2>
<h3 id="heading-what-android-does-to-your-app">What Android Does to Your App</h3>
<p>Android manages process priority through a hierarchy. Foreground apps get the highest priority. Apps with running services get elevated priority. Background apps have lower priority. Empty processes and apps with no active components have the lowest priority.</p>
<p>When the system needs memory, it kills processes in order of priority, starting from the lowest. Your background app can be killed at any time. An app with a foreground service is much harder to kill. An active foreground app is essentially never killed by the system.</p>
<p>Android was historically more permissive than iOS. Early Android allowed apps to run services indefinitely in the background. This freedom was abused. Apps ran constantly even when the user hadn't interacted with them in weeks. Battery life suffered, and Android had to respond.</p>
<p>Starting with Android 8.0 Oreo, Google began restricting background services. Apps can no longer start background services when the app itself isn't in the foreground. Each subsequent Android version has tightened these restrictions further. Android is converging toward iOS's model of declared, constrained background work.</p>
<h3 id="heading-foreground-services">Foreground Services</h3>
<p>A foreground service is the most reliable form of background execution on Android. It runs continuously and must display a persistent notification. The notification is mandatory. It's how Android communicates to the user that something is actively happening. The user can see it, expand it for details, and stop it if they choose.</p>
<p>Music players show the currently playing track with playback controls. Navigation apps show the current route with estimated arrival time. File upload apps show a progress bar. Fitness apps show elapsed time and current stats.</p>
<p>Android 14 introduced foreground service types. You must now declare what kind of foreground service you're running. The types are: mediaPlayback, location, dataSync, camera, microphone, phoneCall, remoteMessaging, shortService, health, and systemExempted. This is Android deliberately moving toward the iOS model of declared categories.</p>
<p>Foreground services are the right choice when the user expects something to be actively happening. Playing music. Navigating. Uploading a file. Anything where there is an ongoing activity that the user initiated and expects to continue.</p>
<h3 id="heading-workmanager">WorkManager</h3>
<p>WorkManager is Google's recommended solution for deferrable, guaranteed background work. The key characteristics that define it are important to understand.</p>
<p>Guaranteed means your work will eventually run. Even if the app exits, the user restarts the device, or the system kills your process, WorkManager persists the task to a local database and retries it when conditions allow. This is fundamentally different from a background service that disappears if the app is killed.</p>
<p>Deferrable means you don't control exactly when the work runs. You define constraints and WorkManager waits until those constraints are satisfied. Constraints can include requiring network connectivity, requiring the device to be charging, or requiring the battery to not be low. WorkManager picks the optimal time within those constraints.</p>
<p>Periodic tasks have a minimum interval of 15 minutes. This is enforced by the platform, not WorkManager. Android doesn't allow apps to schedule work more frequently than this to prevent battery abuse.</p>
<p>Under the hood, WorkManager uses JobScheduler on modern Android. It manages the complexity of backward compatibility and constraint handling for you.</p>
<p>WorkManager is the right choice for: syncing data with a server, uploading logs or analytics, processing downloaded files, cleaning up old cache entries, generating thumbnails, or sending queued messages.</p>
<p>WorkManager is the wrong choice for anything that needs to run immediately, at an exact time, or continuously.</p>
<h3 id="heading-doze-mode-and-app-standby">Doze Mode and App Standby</h3>
<p>Doze Mode activates when the device is unplugged, stationary, and the screen has been off for an extended period. In Doze, Android suspends network access, defers WorkManager tasks, ignores wake locks, and defers alarms. The system enters this state to conserve battery when the device is clearly not being used.</p>
<p>The system exits Doze periodically for maintenance windows during which deferred work can run. These windows become less frequent the longer the device stays in Doze.</p>
<p>Only high-priority Firebase Cloud Messaging notifications can break through Doze. This is why server-triggered background refresh is so powerful: your server sends a high-priority FCM message, Android wakes the app even in Doze to process it.</p>
<p>App Standby Buckets categorize your app based on how recently and frequently the user has interacted with it. The buckets are Active, Working Set, Frequent, Rare, and Restricted. The bucket your app is in directly affects how much background work it is allowed to do.</p>
<p>Active means the user used your app very recently. Full background execution allowed.</p>
<p>Working Set means the user uses your app regularly. Slight restrictions on how frequently background work can run.</p>
<p>Frequent means the user uses your app often but not daily. More restrictions.</p>
<p>Rare means the user barely uses your app. Significant restrictions. WorkManager tasks get delayed substantially.</p>
<p>Restricted means the app has been flagged for bad behavior or is almost never used. Background work is heavily throttled. The user or the system has effectively put your app on notice.</p>
<p>If your app ends up in the Rare or Restricted bucket, background sync becomes unreliable. The way to avoid this is straightforward: build an app people actually use regularly.</p>
<h2 id="heading-flutter-implementation">Flutter Implementation</h2>
<p>Now let's look at how Flutter engineers implement background work using the native mechanisms above.</p>
<h3 id="heading-setup-project-structure">Setup: Project Structure</h3>
<p>Background work in Flutter requires coordination between your Dart code and the native platform. The packages handle most of this, but there are configuration steps on both the iOS and Android sides that you must complete for the work to actually run.</p>
<h4 id="heading-workmanager"><code>workmanager</code></h4>
<p>The workmanager package is the most widely used solution for deferrable background tasks in Flutter. It wraps Android WorkManager and iOS BGTaskScheduler.</p>
<p>Add the dependency:</p>
<pre><code class="language-yaml">dependencies:
  workmanager: ^0.5.2
</code></pre>
<p>On Android, no additional configuration is needed beyond the dependency. WorkManager is part of AndroidX and is available on all modern Android devices.</p>
<p>On iOS, add your task identifiers to Info.plist:</p>
<pre><code class="language-xml">&lt;key&gt;BGTaskSchedulerPermittedIdentifiers&lt;/key&gt;
&lt;array&gt;
  &lt;string&gt;com.yourapp.syncTask&lt;/string&gt;
  &lt;string&gt;com.yourapp.cleanupTask&lt;/string&gt;
&lt;/array&gt;
</code></pre>
<p>Also add Background Modes capability in Xcode and enable Background fetch and Background processing.</p>
<p>The background callback must be a top-level function. It can't be inside a class:</p>
<pre><code class="language-dart">@pragma('vm:entry-point')
void callbackDispatcher() {
  Workmanager().executeTask((taskName, inputData) async {
    switch (taskName) {
      case 'syncUserData':
        await syncUserData(inputData);
        break;
      case 'cleanupOldFiles':
        await cleanupOldFiles();
        break;
      default:
        print('Unknown task: $taskName');
    }
    return Future.value(true);
  });
}
</code></pre>
<p>The <code>@pragma('vm:entry-point')</code> annotation is critical. Without it, the Dart tree shaker may remove this function during release builds because it appears to be uncalled from Dart code. The platform calls it by name, not through Dart, so the tree shaker can't detect the reference.</p>
<p>Returning <code>true</code> from the task tells WorkManager the task succeeded. Returning <code>false</code> tells it the task failed and should be retried.</p>
<p>Initialize workmanager in your main function:</p>
<pre><code class="language-dart">void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  await Workmanager().initialize(
    callbackDispatcher,
    isInDebugMode: kDebugMode,
  );

  runApp(const MyApp());
}
</code></pre>
<p><code>isInDebugMode: true</code> logs detailed information about task scheduling and execution. Turn this off in production.</p>
<p>Registering a one-time task:</p>
<pre><code class="language-dart">Future&lt;void&gt; scheduleDataSync() async {
  await Workmanager().registerOneOffTask(
    'syncUserData',
    'syncUserData',
    initialDelay: const Duration(minutes: 5),
    constraints: Constraints(
      networkType: NetworkType.connected,
      requiresBatteryNotLow: true,
    ),
    inputData: {
      'userId': currentUser.id,
      'syncType': 'full',
    },
  );
}
</code></pre>
<p>The task will run once, after a minimum 5-minute delay, only when the device has network connectivity and the battery isn't low. The inputData map is passed to the callback and available as the <code>inputData</code> parameter in <code>executeTask</code>.</p>
<p>Registering a periodic task:</p>
<pre><code class="language-dart">Future&lt;void&gt; schedulePeriodicSync() async {
  await Workmanager().registerPeriodicTask(
    'periodicSync',
    'syncUserData',
    frequency: const Duration(hours: 1),
    constraints: Constraints(
      networkType: NetworkType.connected,
    ),
  );
}
</code></pre>
<p>The minimum frequency is 15 minutes enforced by the platform. If you set a shorter interval, it gets rounded up to 15 minutes. On iOS, BGTaskScheduler controls the actual timing and may run the task less frequently based on device conditions.</p>
<p>Cancelling tasks:</p>
<pre><code class="language-dart">// cancel one specific task
await Workmanager().cancelByUniqueName('periodicSync');

// cancel all registered tasks
await Workmanager().cancelAll();
</code></pre>
<h4 id="heading-flutterbackgroundservice"><code>flutter_background_service</code></h4>
<p>The workmanager package is great for deferrable work, but sometimes you need something that runs continuously, like a health monitor, a real-time data collector, or a persistent connection.</p>
<p>For that, flutter_background_service creates a long-running service. On Android, this becomes a Foreground Service with a persistent notification. On iOS, it uses a combination of background modes.</p>
<p>Add the dependency:</p>
<pre><code class="language-yaml">dependencies:
  flutter_background_service: ^5.0.5
  flutter_local_notifications: ^17.0.0
</code></pre>
<p>The background service entry point, again, must be a top-level function:</p>
<pre><code class="language-dart">@pragma('vm:entry-point')
void onStart(ServiceInstance service) async {
  DartPluginRegistrant.ensureInitialized();

  if (service is AndroidServiceInstance) {
    service.on('setAsForeground').listen((event) {
      service.setAsForegroundService();
    });

    service.on('setAsBackground').listen((event) {
      service.setAsBackgroundService();
    });
  }

  service.on('stopService').listen((event) {
    service.stopSelf();
  });

  // your actual background work runs here
  Timer.periodic(const Duration(seconds: 30), (timer) async {
    if (service is AndroidServiceInstance) {
      if (await service.isForegroundService()) {
        service.setForegroundNotificationInfo(
          title: 'App is running',
          content: 'Last sync: ${DateTime.now()}',
        );
      }
    }

    // do the actual work
    await performBackgroundSync();

    // send data to the main isolate if needed
    service.invoke('update', {
      'lastSync': DateTime.now().toIso8601String(),
    });
  });
}
</code></pre>
<p>Initialize the service:</p>
<pre><code class="language-dart">Future&lt;void&gt; initializeBackgroundService() async {
  final service = FlutterBackgroundService();

  const AndroidNotificationChannel channel = AndroidNotificationChannel(
    'background_service',
    'Background Service',
    description: 'This channel is used for the background service notification',
    importance: Importance.low,
  );

  final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
      FlutterLocalNotificationsPlugin();

  await flutterLocalNotificationsPlugin
      .resolvePlatformSpecificImplementation&lt;
          AndroidFlutterLocalNotificationsPlugin&gt;()
      ?.createNotificationChannel(channel);

  await service.configure(
    androidConfiguration: AndroidConfiguration(
      onStart: onStart,
      autoStart: true,
      isForegroundMode: true,
      notificationChannelId: 'background_service',
      initialNotificationTitle: 'App Running',
      initialNotificationContent: 'Background sync active',
      foregroundServiceNotificationId: 888,
    ),
    iosConfiguration: IosConfiguration(
      autoStart: true,
      onForeground: onStart,
      onBackground: onIosBackground,
    ),
  );

  await service.startService();
}
</code></pre>
<p>Communicating between the background service and your UI:</p>
<pre><code class="language-dart">// in your widget, listen for updates from the background service
class HomeScreen extends StatefulWidget {
  @override
  State&lt;HomeScreen&gt; createState() =&gt; _HomeScreenState();
}

class _HomeScreenState extends State&lt;HomeScreen&gt; {
  String lastSync = 'Never';

  @override
  void initState() {
    super.initState();
    FlutterBackgroundService().on('update').listen((event) {
      setState(() {
        lastSync = event?['lastSync'] ?? 'Unknown';
      });
    });
  }

  void stopService() {
    FlutterBackgroundService().invoke('stopService');
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Column(
        children: [
          Text('Last sync: $lastSync'),
          ElevatedButton(
            onPressed: stopService,
            child: const Text('Stop Background Service'),
          ),
        ],
      ),
    );
  }
}
</code></pre>
<p>The <code>invoke</code> and <code>on</code> methods create a two-way communication channel between the background isolate and the main isolate. The background service invokes events with data. The UI listens for those events and updates accordingly.</p>
<h4 id="heading-backgroundfetch"><code>background_fetch</code></h4>
<p>For simpler periodic background work where workmanager's full constraint system is more than you need, background_fetch provides a cleaner API.</p>
<pre><code class="language-yaml">dependencies:
  background_fetch: ^1.2.1
</code></pre>
<pre><code class="language-dart">void main() {
  runApp(const MyApp());
  BackgroundFetch.registerHeadlessTask(backgroundFetchHeadlessTask);
}

@pragma('vm:entry-point')
void backgroundFetchHeadlessTask(HeadlessTask task) async {
  String taskId = task.taskId;
  bool isTimeout = task.timeout;

  if (isTimeout) {
    BackgroundFetch.finish(taskId);
    return;
  }

  await performQuickSync();
  BackgroundFetch.finish(taskId);
}
</code></pre>
<p>Configure and start:</p>
<pre><code class="language-dart">Future&lt;void&gt; configureBackgroundFetch() async {
  await BackgroundFetch.configure(
    BackgroundFetchConfig(
      minimumFetchInterval: 15,
      stopOnTerminate: false,
      enableHeadless: true,
      requiresBatteryNotLow: false,
      requiresCharging: false,
      requiresStorageNotLow: false,
      requiresDeviceIdle: false,
      requiredNetworkType: NetworkType.ANY,
    ),
    (taskId) async {
      await performQuickSync();
      BackgroundFetch.finish(taskId);
    },
    (taskId) async {
      // timeout handler
      BackgroundFetch.finish(taskId);
    },
  );
}
</code></pre>
<p>Calling <code>BackgroundFetch.finish(taskId)</code> is mandatory. On iOS, failing to call finish tells the platform your task did not complete correctly, which affects future scheduling. On Android, it signals WorkManager that the task is done.</p>
<h3 id="heading-persisting-data-between-isolates">Persisting Data Between Isolates</h3>
<p>Since the background isolate and the main isolate don't share memory, data must be persisted to disk.</p>
<p>The most common approaches are shared_preferences for simple key-value data and a local database like sqflite or isar for structured data.</p>
<pre><code class="language-dart">// writing from background isolate
@pragma('vm:entry-point')
void callbackDispatcher() {
  Workmanager().executeTask((taskName, inputData) async {
    final prefs = await SharedPreferences.getInstance();

    // fetch new data
    final newData = await fetchFromServer();

    // persist for main isolate to read
    await prefs.setString('lastSyncData', jsonEncode(newData));
    await prefs.setString('lastSyncTime', DateTime.now().toIso8601String());

    return Future.value(true);
  });
}

// reading in main isolate when app comes to foreground
class HomeScreen extends StatefulWidget {
  @override
  State&lt;HomeScreen&gt; createState() =&gt; _HomeScreenState();
}

class _HomeScreenState extends State&lt;HomeScreen&gt; {
  @override
  void initState() {
    super.initState();
    loadLastSyncedData();
  }

  Future&lt;void&gt; loadLastSyncedData() async {
    final prefs = await SharedPreferences.getInstance();
    final data = prefs.getString('lastSyncData');
    final syncTime = prefs.getString('lastSyncTime');

    if (data != null) {
      setState(() {
        // update your state with the synced data
      });
    }
  }
}
</code></pre>
<h2 id="heading-the-decision-framework">The Decision Framework</h2>
<p>Given a background task requirement, here is how to decide which approach to reach for:</p>
<p>Does the user expect something to actively be running, like music playing, navigation running, or a file uploading? Use a Foreground Service via flutter_background_service. The persistent notification isn't just a requirement, it's straightforward communication to the user about what the app is doing.</p>
<p>Does the work need to happen eventually but not necessarily right now? Something like syncing data, uploading logs, processing files, or cleaning the cache. If so, use workmanager. It guarantees the work runs, respects constraints, and survives app restarts and device reboots.</p>
<p>Does the work need to happen on a server-triggered signal? Use Firebase Cloud Messaging with a high-priority silent notification. Your server sends the signal, iOS and Android wake your app, your background isolate handles the work. This breaks through Doze Mode on Android and works with iOS's silent push mechanism.</p>
<p>Does the work need to happen on a simple periodic schedule with minimal constraints? Use background_fetch for a simpler API when workmanager's full constraint system is more than you need.</p>
<p>Does the work need precise timing? Rethink whether it truly needs to happen in the background. If a user sets a reminder for 3pm, a local notification is the right approach. The notification fires at the exact time regardless of whether the app is in the background.</p>
<p>One note about iOS specifically: no Flutter package can work around Apple's restrictions. If you register a BGAppRefreshTask, iOS decides when it runs. If you set constraints on WorkManager, Android decides when they're satisfied. The platform is in control. Your job is to declare what you need clearly, handle it correctly when the platform gives you the window, and build your app to be resilient when the background work runs later than expected.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Background execution on mobile isn't a Flutter problem or a Dart problem. It's a platform problem that Flutter sits on top of.</p>
<p>iOS is restrictive by design. You declare the specific category of background work you need. Apple evaluates whether that use case is legitimate. When granted, the platform gives you controlled, time-limited windows. Exceed those windows and the system terminates your task.</p>
<p>Android started permissive and has been getting stricter with every major version. Foreground Services give reliable continuous execution with a visible notification. WorkManager gives guaranteed deferred execution with constraints. Doze Mode and App Standby Buckets restrict everything else based on device state and user behavior.</p>
<p>Flutter bridges both through packages that map to the native APIs. workmanager covers deferrable work on both platforms. flutter_background_service covers continuous work with a foreground notification. background_fetch covers simple periodic work with a cleaner API.</p>
<p>The engineers who succeed with mobile background work are the ones who understand what the platform is actually doing and design with those constraints in mind. They don't fight the platform. They declare what they need, handle the windows they're given, persist state properly across isolate boundaries, and build their systems to be resilient when background work is delayed or deferred.</p>
<p>That's how background work actually gets done on mobile.</p>
<p>Understanding the core of background processes and app lifecycle in native and hybrid mobile engineering helps you make an informed architectural decision when selecting the task handler needed to run a specific task.</p>
<p>Happy Coding!!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Chain of Responsibility Design Pattern: Decoupling Complex Business Rules, One Handler at a Time ]]>
                </title>
                <description>
                    <![CDATA[ Every system, at some point, ends up with a function that nobody wants to touch. It starts small: a simple validation check, an if statement here, another there. Then requirements grow and more condit ]]>
                </description>
                <link>https://www.freecodecamp.org/news/chain-of-responsibility-design-pattern-decoupling-complex-business-rules/</link>
                <guid isPermaLink="false">6a88b8d9225da88c02eee6f5</guid>
                
                    <category>
                        <![CDATA[ design patterns ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Behavioral Design Pattern ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ chain of responsibility ]]>
                    </category>
                
                    <category>
                        <![CDATA[ clean code ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Fri, 21 Aug 2026 20:45:13 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/779882b9-29ec-4337-b3ab-96ccf752638c.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every system, at some point, ends up with a function that nobody wants to touch.</p>
<p>It starts small: a simple validation check, an if statement here, another there. Then requirements grow and more conditions get added. The function gets longer. Someone adds a comment that says "don't modify without reading the full thing first." The function becomes a rite of passage. New developers are warned about it during onboarding.</p>
<p>This is what happens when complex business rules pile up in one place without a deliberate structure to contain them.</p>
<p>The Chain of Responsibility pattern exists to prevent exactly this. Instead of one method that knows everything and does everything, you build a chain of focused handlers. Each handler knows one rule and checks whether the request passes its rule. If it does, the request moves forward to the next handler. If it doesn't, the chain stops right there.</p>
<p>No handler knows how long the chain is. No handler knows what comes before or after it. Each one just does its job and decides: stop here, or pass it forward.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-the-chain-of-responsibility-pattern">What is the Chain of Responsibility 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-real-world-example-one-transaction-approval-flow">Real World Example One: Transaction Approval Flow</a></p>
</li>
<li><p><a href="#heading-real-world-example-two-user-onboarding-validation">Real World Example Two: User Onboarding Validation</a></p>
</li>
<li><p><a href="#heading-what-makes-these-two-examples-interesting-together">What Makes These Two Examples Interesting Together</a></p>
</li>
<li><p><a href="#heading-when-to-use-the-chain-of-responsibility-pattern">When to Use the Chain of Responsibility 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-chain-of-responsibility-pattern">What is the Chain of Responsibility Pattern?</h2>
<p>The Chain of Responsibility is a behavioral design pattern that lets you pass a request along a chain of handlers. Each handler in the chain decides either to process the request and stop the chain, or to pass the request to the next handler.</p>
<p>The pattern gives you three things that matter in production systems.</p>
<p>First, it decouples the sender of a request from its receivers. The code that initiates a transaction validation doesn't know which handler will ultimately process it or stop it. It just starts the chain.</p>
<p>Second, it gives you a single responsibility per handler. Each handler owns exactly one business rule. When that rule changes, you modify one class. Nothing else changes.</p>
<p>Third, it makes the chain configurable. You can add, remove, or reorder handlers without touching existing handler code. A new compliance requirement becomes a new handler plugged into the chain, not a new branch inside an existing method.</p>
<h2 id="heading-the-problem-it-solves">The Problem It Solves</h2>
<p>Here's what transaction validation looks like without the pattern:</p>
<pre><code class="language-dart">void handleTransaction(Transaction transaction) {
  if (transaction.isFraud) {
    // block transaction
    return;
  }

  if (!transaction.isKycVerified) {
    // reject transaction
    return;
  }

  if (!transaction.isAccountActive) {
    // reject transaction
    return;
  }

  if (transaction.amount &lt; 50000) {
    // junior officer approval
    return;
  }

  if (transaction.amount &lt;= 200000) {
    // mid level approval
    return;
  }

  if (transaction.amount &lt;= 1000000) {
    // manager approval
    return;
  }

  // executive approval
}
</code></pre>
<p>This works today. Tomorrow your compliance team adds a credit score check. Your fraud team adds a velocity check. Your legal team adds a sanctions screening step. Your product manager adds a daily limit check.</p>
<p>Every new rule goes into this same method. The method grows to fifty lines. Then a hundred. The conditions interact in ways that are hard to reason about. Testing it requires setting up every possible combination of flags. A bug in one condition can affect every other condition below it.</p>
<p>The Chain of Responsibility pattern says: each rule gets its own handler. Chain the handlers together. The method that starts the chain doesn't need to know any of the rules. It just starts the chain and gets out of the way.</p>
<h2 id="heading-core-components">Core Components</h2>
<p>The pattern has three building blocks.</p>
<h3 id="heading-1-the-handler-interface">1. The Handler Interface</h3>
<p>This is the contract every handler in the chain must implement. It declares the method for handling a request and provides the mechanism for linking handlers together. Every concrete handler extends or implements this.</p>
<h3 id="heading-2-the-concrete-handlers">2. The Concrete Handlers</h3>
<p>These are the actual implementations. Each one owns exactly one business rule. It checks whether the request satisfies its rule. If the rule fails, it stops the chain and handles the failure. If the rule passes, it calls the next handler and passes the request forward.</p>
<h3 id="heading-3-the-chain">3. The Chain</h3>
<p>This isn't a class. It's the act of connecting handlers together using the <code>setNext</code> method. You build the chain in your composition root or your dependency injection setup. The order you connect them is the order they execute.</p>
<h2 id="heading-real-world-example-one-transaction-approval-flow">Real World Example One: Transaction Approval Flow</h2>
<p>A fintech platform processes thousands of transactions daily. Before any transaction is approved, it must pass through several validation and approval gates. Each gate is independent. Each one has a single responsibility.</p>
<p>The gates in order:</p>
<ol>
<li><p>Fraud check: is this transaction flagged as fraudulent?</p>
</li>
<li><p>KYC verification: has the user completed identity verification?</p>
</li>
<li><p>Account status: is the account active and in good standing?</p>
</li>
<li><p>Approval level: which officer tier has the authority to approve this amount?</p>
</li>
</ol>
<h3 id="heading-the-transaction-model">The Transaction Model</h3>
<pre><code class="language-dart">class Transaction {
  final num amount;
  final bool isFraud;
  final bool isKycVerified;
  final bool isAccountActive;

  const Transaction({
    required this.amount,
    required this.isFraud,
    required this.isKycVerified,
    required this.isAccountActive,
  });
}
</code></pre>
<p>The transaction model carries all the data each handler needs to make its decision. It owns the data and nothing else. No validation logic lives here.</p>
<h3 id="heading-the-handler-interface">The Handler Interface</h3>
<pre><code class="language-dart">abstract class TransactionHandler {
  TransactionHandler? _next;

  void setNext(TransactionHandler handler) {
    _next = handler;
  }

  void handle(Transaction transaction);

  void passToNext(Transaction transaction) {
    if (_next != null) {
      _next!.handle(transaction);
    } else {
      print('End of chain reached with no handler stopping the transaction');
    }
  }
}
</code></pre>
<p><code>TransactionHandler</code> is the contract every handler implements.</p>
<p><code>_next</code> is nullable because the last handler in the chain has no next handler. Making it nullable and checking before calling prevents a null pointer crash at the end of the chain.</p>
<p><code>setNext</code> connects one handler to the next. You call this when building the chain.</p>
<p><code>passToNext</code> is a helper method that every concrete handler calls when its rule passes. It checks whether a next handler exists before calling it. If we reach the end of the chain without any handler stopping the transaction, we log it. In a real system, this would trigger an alert because it means the chain wasn't configured correctly.</p>
<h3 id="heading-the-concrete-handlers">The Concrete Handlers</h3>
<pre><code class="language-dart">class FraudHandler extends TransactionHandler {
  @override
  void handle(Transaction transaction) {
    if (transaction.isFraud) {
      print('Transaction blocked: fraud detected');
      return;
    }
    print('Fraud check passed');
    passToNext(transaction);
  }
}
</code></pre>
<p><code>FraudHandler</code> is the first gate. If the transaction is flagged as fraudulent, it prints a rejection message and returns. The chain stops here. No other handler sees this transaction. If the fraud check passes, it calls <code>passToNext</code> and the transaction moves to the next handler.</p>
<pre><code class="language-dart">class KycHandler extends TransactionHandler {
  @override
  void handle(Transaction transaction) {
    if (!transaction.isKycVerified) {
      print('Transaction blocked: KYC verification incomplete');
      return;
    }
    print('KYC check passed');
    passToNext(transaction);
  }
}
</code></pre>
<p><code>KycHandler</code> checks whether the user has completed identity verification. If they haven't, the chain stops. If they have, the transaction moves forward. This handler knows nothing about fraud checks. It knows nothing about account status. It owns one rule.</p>
<pre><code class="language-dart">class AccountHandler extends TransactionHandler {
  @override
  void handle(Transaction transaction) {
    if (!transaction.isAccountActive) {
      print('Transaction blocked: account is not active');
      return;
    }
    print('Account status check passed');
    passToNext(transaction);
  }
}
</code></pre>
<p><code>AccountHandler</code> checks account status. Same pattern, one rule: stop or pass.</p>
<pre><code class="language-dart">class ApprovalHandler extends TransactionHandler {
  @override
  void handle(Transaction transaction) {
    if (transaction.amount &lt; 50000) {
      print('Approved by Junior Officer — amount: ${transaction.amount}');
      return;
    }

    if (transaction.amount &lt;= 200000) {
      print('Approved by Mid-Level Officer — amount: ${transaction.amount}');
      return;
    }

    if (transaction.amount &lt;= 1000000) {
      print('Approved by Manager — amount: ${transaction.amount}');
      return;
    }

    print('Escalated to Executive Approval — amount: ${transaction.amount}');
    passToNext(transaction);
  }
}
</code></pre>
<p><code>ApprovalHandler</code> is the final gate. It routes the transaction to the appropriate approval tier based on amount. Transactions below 50,000 are approved by a junior officer. Up to 200,000 go to a mid-level officer. Up to 1,000,000 go to a manager. Above that, the transaction is escalated further.</p>
<p>Note that this handler can still call <code>passToNext</code> if the amount exceeds the manager threshold, allowing you to add an executive handler to the chain later without touching <code>ApprovalHandler</code>.</p>
<h3 id="heading-building-and-running-the-chain">Building and Running the Chain</h3>
<pre><code class="language-dart">void main() {
  // create the handlers
  final fraud = FraudHandler();
  final kyc = KycHandler();
  final account = AccountHandler();
  final approval = ApprovalHandler();

  // build the chain
  fraud.setNext(kyc);
  kyc.setNext(account);
  account.setNext(approval);

  // test with a fraudulent transaction
  print('Test 1: Fraudulent Transaction');
  final fraudulentTransaction = Transaction(
    amount: 100000,
    isFraud: true,
    isKycVerified: true,
    isAccountActive: true,
  );
  fraud.handle(fraudulentTransaction);

  // test with unverified KYC
  print('Test 2: KYC Not Verified');
  final unverifiedTransaction = Transaction(
    amount: 50000,
    isFraud: false,
    isKycVerified: false,
    isAccountActive: true,
  );
  fraud.handle(unverifiedTransaction);

  // test with a valid transaction
  print('Test 3: Valid Transaction');
  final validTransaction = Transaction(
    amount: 150000,
    isFraud: false,
    isKycVerified: true,
    isAccountActive: true,
  );
  fraud.handle(validTransaction);

  // test with a high value transaction
  print('Test 4: Executive Level Transaction');
  final executiveTransaction = Transaction(
    amount: 2000000,
    isFraud: false,
    isKycVerified: true,
    isAccountActive: true,
  );
  fraud.handle(executiveTransaction);
}
</code></pre>
<p>Output:</p>
<pre><code class="language-plaintext">Test 1: Fraudulent Transaction
Transaction blocked: fraud detected

Test 2: KYC Not Verified
Fraud check passed
Transaction blocked: KYC verification incomplete

Test 3: Valid Transaction
Fraud check passed
KYC check passed
Account status check passed
Approved by Mid-Level Officer — amount: 150000

Test 4: Executive Level Transaction
Fraud check passed
KYC check passed
Account status check passed
Escalated to Executive Approval — amount: 2000000
End of chain reached with no handler stopping the transaction
</code></pre>
<p>Test 1 stops at the first handler. Test 2 passes fraud but stops at KYC. Test 3 passes all validation handlers and gets routed to the correct approval tier. Test 4 exceeds the manager threshold and gets escalated.</p>
<p>Notice that the calling code always starts from <code>fraud.handle(transaction)</code>. It doesn't know how many handlers exist. It doesn't know which handler will stop the chain. And it doesn't know what the approval tiers are. It just hands the transaction to the first handler and the chain takes over.</p>
<p>When your compliance team adds a User Indemnity check next month, you create a UserIdemnityCheck, add it to the chain, and nothing else changes:</p>
<pre><code class="language-dart">final indemnity = UserIndemnityStatus();

fraud.setNext(indemnity);
indemnity.setNext(kyc);
kyc.setNext(account);
account.setNext(approval);
</code></pre>
<p>One new class and one updated chain setup. Every existing handler untouched.</p>
<h2 id="heading-real-world-example-two-user-onboarding-validation">Real World Example Two: User Onboarding Validation</h2>
<p>A user fills in a registration form and submits it. Before the account is created, the request must pass through several validation steps. If any step fails, the user gets a specific error explaining exactly what went wrong.</p>
<p>The steps in order:</p>
<ol>
<li><p>Email validation: is the email format valid?</p>
</li>
<li><p>Password strength: does the password meet security requirements?</p>
</li>
<li><p>Age verification: is the user old enough to register?</p>
</li>
<li><p>Duplicate account check: does an account already exist with this email?</p>
</li>
<li><p>Account creation: all checks passed, create the account</p>
</li>
</ol>
<h3 id="heading-the-registration-request-model">The Registration Request Model</h3>
<pre><code class="language-dart">class RegistrationRequest {
  final String email;
  final String password;
  final int age;

  const RegistrationRequest({
    required this.email,
    required this.password,
    required this.age,
  });
}
</code></pre>
<h3 id="heading-the-handler-interface">The Handler Interface</h3>
<pre><code class="language-dart">abstract class RegistrationHandler {
  RegistrationHandler? _next;

  void setNext(RegistrationHandler handler) {
    _next = handler;
  }

  void handle(RegistrationRequest request);

  void passToNext(RegistrationRequest request) {
    if (_next != null) {
      _next!.handle(request);
    }
  }
}
</code></pre>
<p>This is the same structure as before. Nullable next, SetNext to build the chain, and PassToNext to move the request forward.</p>
<h3 id="heading-the-concrete-handlers">The Concrete Handlers</h3>
<pre><code class="language-dart">class EmailValidationHandler extends RegistrationHandler {
  @override
  void handle(RegistrationRequest request) {
    final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$');

    if (!emailRegex.hasMatch(request.email)) {
      print('Registration failed: invalid email format — ${request.email}');
      return;
    }

    print('Email validation passed');
    passToNext(request);
  }
}
</code></pre>
<p><code>EmailValidationHandler</code> checks the email format using a regex. If the format is invalid, it stops the chain immediately with a specific message. If it's valid, the request moves forward.</p>
<pre><code class="language-dart">class PasswordStrengthHandler extends RegistrationHandler {
  @override
  void handle(RegistrationRequest request) {
    final password = request.password;
    final hasMinLength = password.length &gt;= 8;
    final hasUppercase = password.contains(RegExp(r'[A-Z]'));
    final hasNumber = password.contains(RegExp(r'[0-9]'));
    final hasSpecialChar = password.contains(RegExp(r'[!@#\$%^&amp;*]'));

    if (!hasMinLength || !hasUppercase || !hasNumber || !hasSpecialChar) {
      print('Registration failed: password does not meet security requirements');
      print('Requirements: 8+ characters, uppercase, number, special character');
      return;
    }

    print('Password strength check passed');
    passToNext(request);
  }
}
</code></pre>
<p><code>PasswordStrengthHandler</code> enforces four password rules in one place. Minimum length, at least one uppercase letter, at least one number, and at least one special character. If any of these fail, the user gets a clear message explaining all the requirements. If all pass, the request moves forward.</p>
<pre><code class="language-dart">class AgeVerificationHandler extends RegistrationHandler {
  final int minimumAge;

  AgeVerificationHandler({this.minimumAge = 18});

  @override
  void handle(RegistrationRequest request) {
    if (request.age &lt; minimumAge) {
      print('Registration failed: user must be at least $minimumAge years old');
      return;
    }

    print('Age verification passed');
    passToNext(request);
  }
}
</code></pre>
<p><code>AgeVerificationHandler</code> checks the user's age against a minimum threshold. Notice that this handler accepts the minimum age as a constructor parameter. This makes it configurable without modifying the class. If the minimum age requirement changes from 18 to 16 for a specific product, you just pass a different value when building the chain.</p>
<pre><code class="language-dart">class DuplicateAccountHandler extends RegistrationHandler {
  final Set&lt;String&gt; existingEmails;

  DuplicateAccountHandler({required this.existingEmails});

  @override
  void handle(RegistrationRequest request) {
    if (existingEmails.contains(request.email)) {
      print('Registration failed: an account already exists with ${request.email}');
      return;
    }

    print('Duplicate account check passed');
    passToNext(request);
  }
}
</code></pre>
<p><code>DuplicateAccountHandler</code> checks whether an account already exists with the provided email. In a real system, this would call a repository or database. Here we use a Set of existing emails to keep the example focused on the pattern.</p>
<pre><code class="language-dart">class AccountCreationHandler extends RegistrationHandler {
  @override
  void handle(RegistrationRequest request) {
    print('All validation passed');
    print('Creating account for: ${request.email}');
    // call account creation service
    print('Account created successfully');
  }
}
</code></pre>
<p><code>AccountCreationHandler</code> is the final handler. It only runs if every previous handler passed the request forward. By the time execution reaches here, the request has been validated on every dimension. This handler simply creates the account.</p>
<h3 id="heading-building-and-running-the-chain">Building and Running the Chain</h3>
<pre><code class="language-dart">void main() {
  final existingEmails = {'existing@seyi.com', 'taken@seyi.com'};

  // create the handlers
  final emailValidation = EmailValidationHandler();
  final passwordStrength = PasswordStrengthHandler();
  final ageVerification = AgeVerificationHandler(minimumAge: 18);
  final duplicateCheck = DuplicateAccountHandler(existingEmails: existingEmails);
  final accountCreation = AccountCreationHandler();

  // build the chain
  emailValidation.setNext(passwordStrength);
  passwordStrength.setNext(ageVerification);
  ageVerification.setNext(duplicateCheck);
  duplicateCheck.setNext(accountCreation);

  // test with invalid email
  print('Test 1: Invalid Email');
  emailValidation.handle(RegistrationRequest(
    email: 'notanemail',
    password: 'SecureP@ss1',
    age: 25,
  ));

  // test with weak password
  print('Test 2: Weak Password');
  emailValidation.handle(RegistrationRequest(
    email: 'user@example.com',
    password: 'weak',
    age: 25,
  ));

  // test with underage user
  print('Test 3: Underage User');
  emailValidation.handle(RegistrationRequest(
    email: 'young@example.com',
    password: 'SecureP@ss1',
    age: 16,
  ));

  // test with duplicate account
  print('Test 4: Duplicate Account');
  emailValidation.handle(RegistrationRequest(
    email: 'existing@example.com',
    password: 'SecureP@ss1',
    age: 25,
  ));

  // test with valid registration
  print('Test 5: Valid Registration');
  emailValidation.handle(RegistrationRequest(
    email: 'newuser@example.com',
    password: 'SecureP@ss1',
    age: 25,
  ));
}
</code></pre>
<p>Output:</p>
<pre><code class="language-plaintext">Test 1: Invalid Email
Registration failed: invalid email format — notanemail

Test 2: Weak Password
Email validation passed
Registration failed: password does not meet security requirements
Requirements: 8+ characters, uppercase, number, special character

Test 3: Underage User
Email validation passed
Password strength check passed
Age verification passed
Registration failed: user must be at least 18 years old

Test 4: Duplicate Account
Email validation passed
Password strength check passed
Age verification passed
Duplicate account check passed
Registration failed: an account already exists with existing@example.com

Test 5: Valid Registration
Email validation passed
Password strength check passed
Age verification passed
Duplicate account check passed
All validation passed
Creating account for: newuser@example.com
Account created successfully
</code></pre>
<p>Each test stops at exactly the right handler. Each failure message is specific. The valid registration flows through all five handlers and creates the account.</p>
<p>When a new requirement arrives, say a phone number verification step before account creation, you create a <code>PhoneVerificationHandler</code> and plug it into the chain between duplicate check and account creation. Five existing handlers remain completely untouched.</p>
<h2 id="heading-what-makes-these-two-examples-interesting-together">What Makes These Two Examples Interesting Together</h2>
<p>The transaction flow and the onboarding flow look similar on the surface, but they represent two different ways the pattern gets used in production.</p>
<p>The transaction flow combines validation handlers and routing handlers in one chain. Fraud, KYC, and account handlers are gates. The approval handler is a router. The chain validates first, then routes. This is common in payment and compliance systems where every transaction must pass multiple independent checks before being directed to the appropriate authority.</p>
<p>The onboarding flow is a pure validation chain. Every handler is a gate. The final handler is the action that runs only if all gates pass. This is common in form processing, API request validation, and any multi-step verification flow.</p>
<p>Both use the same pattern and are configured the same way. The difference is just in what the handlers do when they let the request through.</p>
<h2 id="heading-when-to-use-the-chain-of-responsibility-pattern">When to Use the Chain of Responsibility Pattern</h2>
<p>Use it when you have a request that must pass through multiple independent checks or processing steps.</p>
<p>It's also a good fit when the number of checks or their order might change over time. Adding a new step or reordering existing steps should not require modifying existing handler code.</p>
<p>It works well when each check or processing step has genuinely independent logic. If the steps are deeply interdependent and need to share a lot of state, a single class might be cleaner.</p>
<p>It does well when you want each step to be independently testable. With the chain pattern, testing <code>FraudHandler</code> means creating one handler, calling handle with a transaction, and checking the output. No other handler is involved.</p>
<p>And it's great when different configurations of the chain might be needed in different contexts. A junior officer's system might have a shorter chain than an executive's system. The same handlers, configured differently.</p>
<h2 id="heading-when-not-to-use-it">When Not to Use It</h2>
<p>Avoid the pattern when you only have one or two checks. The overhead of defining an abstract class and multiple concrete classes is not worth it for simple validation.</p>
<p>It's also not the best when the order of processing steps is fixed and will never change. If the chain will always be the same, a simpler sequential function call might be clearer.</p>
<p>Don't use it when handlers need to communicate results back to each other. The pattern works best when each handler makes an independent decision. If Handler B needs to know what Handler A found, consider a different approach.</p>
<p>And it's not a good choice when you need guaranteed execution of all handlers regardless of earlier results. The Chain of Responsibility stops when a handler handles the request. If you need all steps to always run, a middleware pipeline or decorator pattern might suit you better.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The Chain of Responsibility pattern solves the problem that every growing system eventually faces. Business rules accumulate and validation logic expands. A method that started as ten lines becomes a hundred. The conditions interact in ways nobody fully understands anymore. Nobody wants to touch it.</p>
<p>The pattern gives you a way out. Each business rule gets its own handler. Each handler owns one responsibility and makes one decision: stop here, or pass it forward. The chain is built once in the configuration layer. The handlers never need to know about each other.</p>
<p>In the transaction approval flow, adding a new compliance rule means one new handler class. In the onboarding flow, adding a new verification step means one new handler class. In both cases, nothing else changes.</p>
<p>That's the promise of the pattern. Complexity that grows by addition, not by modification. Business rules that are isolated, testable, and replaceable. A system that can absorb new requirements without accumulating more debt every time.</p>
<p>Applying this behavioral pattern helps to bring some level of organization and scalability to your codes and makes it easy to manage based on further business rules to come in the nearest future.</p>
<p>Happy Coding!!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ A Deep Dive into Behavioral Patterns: The Visitor Design Pattern and its Clean Operations Across Complex Object Structures ]]>
                </title>
                <description>
                    <![CDATA[ There's a problem that shows up in almost every growing software system, and most developers don't even realize they're hitting it until the damage is already done. You have a set of objects: differen ]]>
                </description>
                <link>https://www.freecodecamp.org/news/the-visitor-design-pattern-and-its-clean-operations-across-complex-object-structures/</link>
                <guid isPermaLink="false">6a74b21fcf90c22a668963b6</guid>
                
                    <category>
                        <![CDATA[ Design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ design patterns ]]>
                    </category>
                
                    <category>
                        <![CDATA[ design principles ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mobile ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Software Engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software architecture ]]>
                    </category>
                
                    <category>
                        <![CDATA[ visitor design pattern ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Thu, 06 Aug 2026 16:11:11 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/ff25cbd5-72fc-4f17-8d37-ba8dc909de46.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>There's a problem that shows up in almost every growing software system, and most developers don't even realize they're hitting it until the damage is already done.</p>
<p>You have a set of objects: different types, shapes, and data. And at some point, someone asks you to perform an operation on all of them, like exporting them them to PDF, sending them a notification, generating a report, or calculating their fees.</p>
<p>Your first instinct might be to write a function that checks the type and branches accordingly, like an if-else block or switch statement. Something that says: if this is a NewUser, do this. If this is a JointAccountUser, do that. It works, you ship it, and everyone is happy.</p>
<p>Then another operation comes in. And another. Every single time, you go back to the same place and add another branch. The function grows. The class grows. The test surface grows. What started as a clean model is now a god object that knows how to do everything for everyone.</p>
<p>The Visitor Design Pattern exists to break this cycle completely.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-the-visitor-design-pattern">What is the Visitor Design Pattern?</a></p>
</li>
<li><p><a href="#heading-the-problem-the-visitor-pattern-solves">The Problem the Visitor Pattern Solves</a></p>
</li>
<li><p><a href="#heading-core-components">Core Components</a></p>
</li>
<li><p><a href="#heading-real-world-example-one-document-export">Real World Example One: Document Export</a></p>
</li>
<li><p><a href="#heading-real-world-example-two-notification-system">Real World Example Two: Notification System</a></p>
</li>
<li><p><a href="#heading-real-world-example-three-fee-calculation">Real World Example Three: Fee Calculation</a></p>
</li>
<li><p><a href="#heading-the-power-of-combining-all-three-operations">The Power of Combining All Three Operations</a></p>
</li>
<li><p><a href="#heading-when-to-use-the-visitor-pattern">When to Use the Visitor 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-visitor-design-pattern">What is the Visitor Design Pattern?</h2>
<p>The Visitor pattern is a behavioral design pattern that lets you define a new operation on a family of objects without changing the objects themselves.</p>
<p>The key word there is behavioral. Behavioral patterns are about how objects communicate and distribute responsibility. Where creational patterns deal with how objects are created and structural patterns deal with how they are composed, behavioral patterns deal with how they interact and who is responsible for what.</p>
<p>The Visitor pattern specifically deals with the question of who should own an operation when that operation needs to work differently across multiple object types.</p>
<p>The classic answer is: put the operation on each object. Give each class a method that handles the operation for its own type. But this breaks down the moment you have multiple operations, because now every new operation means touching every class. You're spreading one concern across your entire object hierarchy.</p>
<p>The Visitor pattern flips this. Instead of spreading the operation across the objects, you collect it into one place called a Visitor. The objects simply accept the visitor and let it do its work. Adding a new operation means creating a new Visitor. The existing objects don't change at all.</p>
<p>This is the Open/Closed Principle working exactly as intended: open for extension, closed for modification.</p>
<h2 id="heading-the-problem-the-visitor-pattern-solves">The Problem the Visitor Pattern Solves</h2>
<p>Let me show you exactly what this looks like without the Visitor pattern.</p>
<p>Say you have a fintech platform with four types of users: existing customers, new customers, minor account holders, and joint account holders. Your product manager comes in and asks you to add document export. Every user type should be exportable to PDF, Excel, and CSV.</p>
<p>Without Visitor, the natural approach looks something like this:</p>
<pre><code class="language-dart">class ExistingUser {
  final int id;
  final String firstName;
  final String lastName;
  final DateTime lastPaymentDate;
  final num accountBalance;

  String exportToPdf() {
    return '$firstName\n$lastName\n$lastPaymentDate\n$accountBalance';
  }

  String exportToExcel() {
    return '$firstName,$lastName,$lastPaymentDate,$accountBalance';
  }

  String exportToCsv() {
    return '"$firstName","$lastName","$lastPaymentDate","$accountBalance"';
  }
}
</code></pre>
<p>And you repeat this for NewUser, MinorAccountUser, and JointAccountUser. Twelve methods spread across four classes just for document export.</p>
<p>Now the product manager comes back. They want notifications: email, SMS, and Push. Back you go to all four classes, adding three more methods each. Twelve more methods spread across the same four classes.</p>
<p>Then they want fee calculation. Then they want KYC status checks. Every new operation multiplies across every user type. The classes grow, the reasons to change multiply, and testing becomes painful.</p>
<p>This is the exact problem the Visitor pattern was built to solve.</p>
<h2 id="heading-core-components">Core Components</h2>
<p>The Visitor pattern has four core components. Understanding each one before looking at code makes the implementation much easier to follow.</p>
<h3 id="heading-the-visitor-interface">The Visitor Interface</h3>
<p>This is the contract that every visitor must implement. It declares one method per object type it needs to visit. A visitor that handles four user types declares four visit methods, one for each type.</p>
<h3 id="heading-the-concrete-visitors">The Concrete Visitors</h3>
<p>These are the real implementations of the Visitor interface. Each one represents a single operation and knows how to handle every object type. A PdfHandler is a concrete visitor. An ExcelHandler is a concrete visitor. A SmsNotificationHandler is a concrete visitor. Each one has one job and knows how to do that job for every user type.</p>
<h3 id="heading-the-consumer-interface-also-called-element-or-acceptor">The Consumer Interface (also called Element or Acceptor)</h3>
<p>This is the contract that every object in the hierarchy must implement. It declares a single accept method that takes a Visitor and calls the right visit method on it. This is the double dispatch mechanism that makes the pattern work.</p>
<h3 id="heading-the-concrete-consumers">The Concrete Consumers</h3>
<p>These are the real objects in the hierarchy: ExistingCustomers, NewCustomers, MinorCustomer, and JointCustomer. Each one implements accept by calling the specific visit method that corresponds to its own type.</p>
<p>Think of it this way. The Visitor interface is implemented by every operation you want to perform: PdfHandler, ExcelHandler, and CsvHandler. Each of these knows how to handle all four user types.</p>
<p>The Consumer interface is implemented by every object in the hierarchy: ExistingCustomers, NewCustomers, MinorCustomer, and JointCustomer. Each of these knows how to receive a visitor and route it to the correct method.</p>
<p>When you call <code>existingCustomer.accept(pdfHandler)</code>, ExistingCustomers calls <code>pdfHandler.visitExistingCustomer(this)</code> and passes itself as the argument. The right method fires automatically. There's no type checking, if-else, or switch. The object tells the visitor who it is, and the visitor knows exactly what to do with that information.</p>
<h2 id="heading-real-world-example-one-document-export">Real World Example One: Document Export</h2>
<p>This is a real scenario from a fintech platform. There are four user types with different data structures, all needing to export their information to three document formats: PDF, Excel, and CSV.</p>
<h3 id="heading-step-1-define-the-user-models">Step 1: Define the User Models</h3>
<pre><code class="language-dart">class ExistingUser {
  final int id;
  final String firstName;
  final String lastName;
  final DateTime lastPaymentDate;
  final num accountBalance;

  const ExistingUser({
    required this.id,
    required this.firstName,
    required this.lastName,
    required this.lastPaymentDate,
    required this.accountBalance,
  });
}

class NewUser {
  final String firstName;
  final String lastName;

  const NewUser({
    required this.firstName,
    required this.lastName,
  });
}

class MinorAccountUser {
  final int age;
  final int guardianId;
  final String firstName;
  final String lastName;
  final String guardianName;

  const MinorAccountUser({
    required this.age,
    required this.guardianId,
    required this.firstName,
    required this.lastName,
    required this.guardianName,
  });
}

class JointAccountUser {
  final int jointAccountId;
  final List&lt;String&gt; accountHoldersInfo;
  final num accountBalance;

  const JointAccountUser({
    required this.jointAccountId,
    required this.accountHoldersInfo,
    required this.accountBalance,
  });
}
</code></pre>
<p>We have four models. Each one owns its own data and nothing else. There's no export logic or notification logic. And no business operations of any kind. Just clean data structures.</p>
<p>This is exactly how it should be. The model's job is to hold data. The visitor's job is to operate on it.</p>
<h3 id="heading-step-2-define-the-visitor-and-consumer-interfaces">Step 2: Define the Visitor and Consumer Interfaces</h3>
<pre><code class="language-dart">abstract class UserVisitor&lt;T&gt; {
  T visitExistingCustomer(ExistingUser user);
  T visitNewCustomer(NewUser user);
  T visitMinorCustomer(MinorAccountUser user);
  T visitJointCustomer(JointAccountUser user);
}

abstract class UserConsumer {
  T accept&lt;T&gt;(UserVisitor&lt;T&gt; visitor);
}
</code></pre>
<p><code>UserVisitor&lt;T&gt;</code> is generic. The type parameter <code>T</code> represents what the visitor returns. A document export visitor returns a String. A fee calculation visitor might return a double. A validation visitor might return a bool. The same pattern works for any return type.</p>
<p><code>UserConsumer</code> declares the accept method. Every object in the hierarchy must implement this. The accept method is what makes the double dispatch work. The object receives the visitor and immediately calls the right visit method on it, passing itself as the argument.</p>
<h3 id="heading-step-3-implement-the-concrete-consumers">Step 3: Implement the Concrete Consumers</h3>
<pre><code class="language-dart">class ExistingCustomers implements UserConsumer {
  final ExistingUser user;
  ExistingCustomers({required this.user});

  @override
  T accept&lt;T&gt;(UserVisitor&lt;T&gt; visitor) {
    return visitor.visitExistingCustomer(user);
  }
}

class NewCustomers implements UserConsumer {
  final NewUser user;
  NewCustomers({required this.user});

  @override
  T accept&lt;T&gt;(UserVisitor&lt;T&gt; visitor) {
    return visitor.visitNewCustomer(user);
  }
}

class MinorCustomer implements UserConsumer {
  final MinorAccountUser user;
  MinorCustomer({required this.user});

  @override
  T accept&lt;T&gt;(UserVisitor&lt;T&gt; visitor) {
    return visitor.visitMinorCustomer(user);
  }
}

class JointCustomer implements UserConsumer {
  final JointAccountUser user;
  JointCustomer({required this.user});

  @override
  T accept&lt;T&gt;(UserVisitor&lt;T&gt; visitor) {
    return visitor.visitJointCustomer(user);
  }
}
</code></pre>
<p>Each consumer wraps one user model and implements accept by forwarding to the correct visit method. This is the entire job of a concrete consumer. It knows who it is, and it tells the visitor by calling the right method.</p>
<p>Notice that none of these classes know anything about PDF, Excel, CSV, email, SMS, or any operation. They're completely decoupled from every operation that will ever be performed on them.</p>
<h3 id="heading-step-4-implement-the-concrete-visitors">Step 4: Implement the Concrete Visitors</h3>
<pre><code class="language-dart">class PdfHandler implements UserVisitor&lt;String&gt; {
  @override
  String visitExistingCustomer(ExistingUser user) {
    return '${user.firstName} ${user.lastName}'
        '\nBalance: ${user.accountBalance}'
        '\nLast Payment: ${user.lastPaymentDate}';
  }

  @override
  String visitNewCustomer(NewUser user) {
    return '${user.firstName} ${user.lastName}';
  }

  @override
  String visitMinorCustomer(MinorAccountUser user) {
    return '${user.firstName} ${user.lastName}'
        '\nAge: ${user.age}'
        '\nGuardian: ${user.guardianName} (ID: ${user.guardianId})';
  }

  @override
  String visitJointCustomer(JointAccountUser user) {
    final holders = user.accountHoldersInfo.join(', ');
    return 'Joint Account ID: ${user.jointAccountId}'
        '\nHolders: $holders'
        '\nBalance: ${user.accountBalance}';
  }
}

class ExcelHandler implements UserVisitor&lt;String&gt; {
  @override
  String visitExistingCustomer(ExistingUser user) {
    return '${user.firstName}\t${user.lastName}'
        '\t${user.accountBalance}\t${user.lastPaymentDate}';
  }

  @override
  String visitNewCustomer(NewUser user) {
    return '${user.firstName}\t${user.lastName}';
  }

  @override
  String visitMinorCustomer(MinorAccountUser user) {
    return '${user.firstName}\t${user.lastName}'
        '\t${user.age}\t${user.guardianName}\t${user.guardianId}';
  }

  @override
  String visitJointCustomer(JointAccountUser user) {
    final holders = user.accountHoldersInfo.join('\t');
    return '${user.jointAccountId}\t$holders\t${user.accountBalance}';
  }
}

class CsvHandler implements UserVisitor&lt;String&gt; {
  @override
  String visitExistingCustomer(ExistingUser user) {
    return '"${user.firstName}","${user.lastName}"'
        ',"${user.accountBalance}","${user.lastPaymentDate}"';
  }

  @override
  String visitNewCustomer(NewUser user) {
    return '"${user.firstName}","${user.lastName}"';
  }

  @override
  String visitMinorCustomer(MinorAccountUser user) {
    return '"${user.firstName}","${user.lastName}"'
        ',"${user.age}","${user.guardianName}","${user.guardianId}"';
  }

  @override
  String visitJointCustomer(JointAccountUser user) {
    final holders = user.accountHoldersInfo.map((h) =&gt; '"$h"').join(',');
    return '"${user.jointAccountId}",$holders,"${user.accountBalance}"';
  }
}
</code></pre>
<p>Each handler implements the visitor interface and knows exactly how to format each user type for its specific document format. PdfHandler uses newlines and labels. ExcelHandler uses tabs. CsvHandler wraps values in quotes and separates with commas.</p>
<p>The formatting logic for each document type lives in exactly one class. If the PDF format changes, you touch only PdfHandler. If the CSV format changes, you touch only CsvHandler. The user models never change.</p>
<h3 id="heading-step-5-use-it">Step 5: Use It</h3>
<pre><code class="language-dart">void existingUserLogic() {
  final customer = ExistingCustomers(
    user: ExistingUser(
      id: 10,
      firstName: 'Oluwaseyi',
      lastName: 'Fatunmole',
      lastPaymentDate: DateTime.now(),
      accountBalance: 7373773.39,
    ),
  );

  final pdf = customer.accept(PdfHandler());
  final excel = customer.accept(ExcelHandler());
  final csv = customer.accept(CsvHandler());

  print('PDF:\n$pdf\n');
  print('Excel:\n$excel\n');
  print('CSV:\n$csv\n');
}

void newUserLogic() {
  final customer = NewCustomers(
    user: NewUser(
      firstName: 'Oluwaseyi',
      lastName: 'Fatunmole',
    ),
  );

  customer.accept(PdfHandler());
  customer.accept(ExcelHandler());
  customer.accept(CsvHandler());
}

void minorUserLogic() {
  final customer = MinorCustomer(
    user: MinorAccountUser(
      age: 15,
      guardianId: 82882,
      firstName: 'Oluwaseyi',
      lastName: 'Fatunmole',
      guardianName: 'Inioluwa',
    ),
  );

  customer.accept(PdfHandler());
  customer.accept(ExcelHandler());
  customer.accept(CsvHandler());
}

void jointUserLogic() {
  final customer = JointCustomer(
    user: JointAccountUser(
      jointAccountId: 92,
      accountHoldersInfo: [
        'Oluwaseyi',
        'Aderonke',
        'Inioluwa',
        'Tiwaloluwa',
      ],
      accountBalance: 9200020202.22,
    ),
  );

  customer.accept(PdfHandler());
  customer.accept(ExcelHandler());
  customer.accept(CsvHandler());
}
</code></pre>
<p>The same customer object accepts any visitor with the same call. The type dispatch happens automatically through the accept method. There's no type checking anywhere in the calling code, and no if-else or switch. Just <code>customer.accept(handler)</code> and the right method fires.</p>
<p>Now think about what happens when you need to add an XML export. You create one new class, XmlHandler, implement the four visit methods, and that's it. You don't touch ExistingUser, NewUser, MinorAccountUser, JointAccountUser, or any of the existing handlers. The system is genuinely open for extension and closed for modification.</p>
<h2 id="heading-real-world-example-two-notification-system">Real World Example Two: Notification System</h2>
<p>Here we have the same four user types and the same pattern. But it's a different operation entirely.</p>
<p>Your platform needs to notify users about account events. But not every user type should be notified the same way.</p>
<p>Existing users get email and push notifications. New users only get email because they haven't fully set up their profile yet. Minor account users get SMS to their guardian's number. Joint account users get notified on all channels because multiple people share the account.</p>
<p>Without the Visitor pattern, this logic would spread across all four user models or collapse into one enormous function full of type checks. With Visitor, it lives in three focused classes.</p>
<h3 id="heading-the-notification-visitor-interface">The Notification Visitor Interface</h3>
<pre><code class="language-dart">abstract class NotificationVisitor {
  void visitExistingCustomer(ExistingUser user);
  void visitNewCustomer(NewUser user);
  void visitMinorCustomer(MinorAccountUser user);
  void visitJointCustomer(JointAccountUser user);
}
</code></pre>
<p>This visitor returns void because notifications are side effects. They send messages, they don't return values.</p>
<h3 id="heading-the-concrete-notification-visitors">The Concrete Notification Visitors</h3>
<pre><code class="language-dart">class EmailNotificationHandler implements NotificationVisitor {
  @override
  void visitExistingCustomer(ExistingUser user) {
    print('Sending email to existing customer: ${user.firstName}');
    // email service call with full account details
  }

  @override
  void visitNewCustomer(NewUser user) {
    print('Sending welcome email to new customer: ${user.firstName}');
    // welcome email with onboarding steps
  }

  @override
  void visitMinorCustomer(MinorAccountUser user) {
    print('Sending email to guardian: ${user.guardianName}');
    // email goes to guardian, not the minor
  }

  @override
  void visitJointCustomer(JointAccountUser user) {
    for (final holder in user.accountHoldersInfo) {
      print('Sending email to joint holder: $holder');
      // all account holders get notified
    }
  }
}

class SmsNotificationHandler implements NotificationVisitor {
  @override
  void visitExistingCustomer(ExistingUser user) {
    print('Sending SMS to existing customer: ${user.firstName}');
  }

  @override
  void visitNewCustomer(NewUser user) {
    // new users are not SMS-verified yet, skip
    print('New customer ${user.firstName} not SMS-eligible yet');
  }

  @override
  void visitMinorCustomer(MinorAccountUser user) {
    print('Sending SMS to guardian ${user.guardianName} for minor ${user.firstName}');
    // SMS goes to guardian's registered number
  }

  @override
  void visitJointCustomer(JointAccountUser user) {
    for (final holder in user.accountHoldersInfo) {
      print('Sending SMS to joint holder: $holder');
    }
  }
}

class PushNotificationHandler implements NotificationVisitor {
  @override
  void visitExistingCustomer(ExistingUser user) {
    print('Push notification to existing customer: ${user.firstName}');
  }

  @override
  void visitNewCustomer(NewUser user) {
    print('Push notification to new customer: ${user.firstName}');
  }

  @override
  void visitMinorCustomer(MinorAccountUser user) {
    // minors do not have the app installed yet, guardian gets push
    print('Push notification to guardian: ${user.guardianName}');
  }

  @override
  void visitJointCustomer(JointAccountUser user) {
    for (final holder in user.accountHoldersInfo) {
      print('Push notification to joint holder: $holder');
    }
  }
}
</code></pre>
<p>Each handler knows the specific rules for each user type. <code>SmsNotificationHandler</code> knows that new users aren't SMS-verified yet. <code>PushNotificationHandler</code> knows that minor account notifications go to the guardian. <code>EmailNotificationHandler</code> knows that joint account holders all need to be notified individually.</p>
<p>This business logic lives in exactly one place per notification channel. When the rules change (and they always change), you update one class.</p>
<h3 id="heading-using-the-notification-visitors">Using the Notification Visitors</h3>
<pre><code class="language-dart">void notifyExistingUser() {
  final customer = ExistingCustomers(
    user: ExistingUser(
      id: 10,
      firstName: 'Oluwaseyi',
      lastName: 'Fatunmole',
      lastPaymentDate: DateTime.now(),
      accountBalance: 7373773.39,
    ),
  );

  customer.accept(EmailNotificationHandler());
  customer.accept(SmsNotificationHandler());
  customer.accept(PushNotificationHandler());
}

void notifyMinorUser() {
  final customer = MinorCustomer(
    user: MinorAccountUser(
      age: 15,
      guardianId: 82882,
      firstName: 'Oluwaseyi',
      lastName: 'Fatunmole',
      guardianName: 'Inioluwa',
    ),
  );

  // all three channels fire, each with minor-specific rules
  customer.accept(EmailNotificationHandler());
  customer.accept(SmsNotificationHandler());
  customer.accept(PushNotificationHandler());
}

void notifyJointUser() {
  final customer = JointCustomer(
    user: JointAccountUser(
      jointAccountId: 92,
      accountHoldersInfo: [
        'Oluwaseyi',
        'Aderonke',
        'Inioluwa',
        'Tiwaloluwa',
      ],
      accountBalance: 9200020202.22,
    ),
  );

  customer.accept(EmailNotificationHandler());
  customer.accept(SmsNotificationHandler());
  customer.accept(PushNotificationHandler());
}
</code></pre>
<p>The calling code is identical regardless of the user type or the notification channel. The dispatch is automatic. The rules live inside the visitors.</p>
<p>When WhatsApp notifications become a requirement (and they will), you create one <code>WhatsAppNotificationHandler</code> class with four visit methods. Nothing else changes.</p>
<h2 id="heading-real-world-example-three-fee-calculation">Real World Example Three: Fee Calculation</h2>
<p>Again, we have the same four user types and the same pattern. And once again, we have a completely different operation.</p>
<p>Your platform needs to calculate monthly maintenance fees. But each user type has different rules.</p>
<p>Existing customers pay a flat monthly fee based on their account balance. New customers are fee-exempt for their first three months. Minor account holders pay a reduced fee because their accounts have restricted features. Joint account holders have their fee split equally across all account holders.</p>
<p>Without Visitor, this logic ends up as a giant method somewhere with four branches, or worse, it leaks into the user models themselves. With Visitor, it lives in one focused class.</p>
<h3 id="heading-the-fee-visitor-interface">The Fee Visitor Interface</h3>
<pre><code class="language-dart">abstract class FeeVisitor {
  double visitExistingCustomer(ExistingUser user);
  double visitNewCustomer(NewUser user);
  double visitMinorCustomer(MinorAccountUser user);
  double visitJointCustomer(JointAccountUser user);
}
</code></pre>
<p>This visitor returns a double because fee calculation produces a numeric value.</p>
<h3 id="heading-the-concrete-fee-visitor">The Concrete Fee Visitor</h3>
<pre><code class="language-dart">class MonthlyFeeCalculator implements FeeVisitor {
  @override
  double visitExistingCustomer(ExistingUser user) {
    // 0.5% of account balance, minimum 500, maximum 5000
    final fee = user.accountBalance * 0.005;
    return fee.clamp(500, 5000).toDouble();
  }

  @override
  double visitNewCustomer(NewUser user) {
    // new customers are fee-exempt for the first 3 months
    return 0.0;
  }

  @override
  double visitMinorCustomer(MinorAccountUser user) {
    // flat reduced fee for minor accounts
    return 150.0;
  }

  @override
  double visitJointCustomer(JointAccountUser user) {
    // standard fee split equally across all holders
    const standardFee = 2000.0;
    return standardFee / user.accountHoldersInfo.length;
  }
}
</code></pre>
<p>Every fee rule for every user type lives in this one class. When the fee structure changes for existing customers, you touch one method in one class. When minor account fees are updated, same thing. None of the user models change, and no other visitor changes.</p>
<h3 id="heading-using-the-fee-visitor">Using the Fee Visitor</h3>
<pre><code class="language-dart">void calculateFees() {
  final existingCustomer = ExistingCustomers(
    user: ExistingUser(
      id: 10,
      firstName: 'Oluwaseyi',
      lastName: 'Fatunmole',
      lastPaymentDate: DateTime.now(),
      accountBalance: 7373773.39,
    ),
  );

  final newCustomer = NewCustomers(
    user: NewUser(
      firstName: 'Aderonke',
      lastName: 'Fatunmole',
    ),
  );

  final minorCustomer = MinorCustomer(
    user: MinorAccountUser(
      age: 15,
      guardianId: 82882,
      firstName: 'Inioluwa',
      lastName: 'Fatunmole',
      guardianName: 'Oluwaseyi',
    ),
  );

  final jointCustomer = JointCustomer(
    user: JointAccountUser(
      jointAccountId: 92,
      accountHoldersInfo: [
        'Oluwaseyi',
        'Aderonke',
        'Inioluwa',
        'Tiwaloluwa',
      ],
      accountBalance: 9200020202.22,
    ),
  );

  final calculator = MonthlyFeeCalculator();

  final existingFee = existingCustomer.accept(calculator);
  final newFee = newCustomer.accept(calculator);
  final minorFee = minorCustomer.accept(calculator);
  final jointFee = jointCustomer.accept(calculator);

  print('Existing customer fee: NGN $existingFee');
  print('New customer fee: NGN $newFee');
  print('Minor account fee: NGN $minorFee');
  print('Joint account fee per holder: NGN $jointFee');
}
</code></pre>
<p>The output:</p>
<pre><code class="language-plaintext">Existing customer fee: NGN 5000.0
New customer fee: NGN 0.0
Minor account fee: NGN 150.0
Joint account fee per holder: NGN 500.0
</code></pre>
<p>When a <code>PremiumFeeCalculator</code> is needed for a new tier of customers, you create one new class that implements <code>FeeVisitor</code>. The user models stay exactly as they are. The <code>MonthlyFeeCalculator</code> stays exactly as it is. The accept methods on all four consumers stay exactly as they are.</p>
<h2 id="heading-the-power-of-combining-all-three-operations">The Power of Combining All Three Operations</h2>
<p>Here's what makes the Visitor pattern truly shine in a system like this. You have the same four user types, and you can run any combination of visitors on any of them in the same call chain.</p>
<pre><code class="language-dart">void processUser(UserConsumer customer) {
  final pdf = customer.accept(PdfHandler());
  final csv = customer.accept(CsvHandler());

  customer.accept(EmailNotificationHandler());
  customer.accept(PushNotificationHandler());

  final fee = customer.accept(MonthlyFeeCalculator());

  print('Fee: NGN $fee');
  print('Documents generated and notifications sent');
}
</code></pre>
<p>One function, any user type, any combination of operations. The consumer doesn't care which visitors it receives. The visitors don't care which consumers call them. They speak to each other through the interface, and the interface guarantees everything works correctly.</p>
<p>We have three completely different operations (document export, notifications, and fee calculation) all applied to the same object with the same call pattern. None of these operations know about each other. None of them touch the user models. Each one lives in its own focused class with its own single reason to change.</p>
<h2 id="heading-when-to-use-the-visitor-pattern">When to Use the Visitor Pattern</h2>
<p>Use Visitor when you have a stable set of object types and a growing set of operations on them.</p>
<p>The pattern shines when the object hierarchy is unlikely to change frequently. It's optimized for adding new operations, not new types. Adding a new user type means updating every existing visitor. If your object types change constantly, Visitor creates more work than it saves.</p>
<p>It's also very effective when you need to perform multiple unrelated operations on a family of objects without polluting their classes with that logic. Document export, notification handling, fee calculation, and KYC validation are all unrelated operations. Each belongs in its own visitor, not scattered across the user models.</p>
<p>Visitor also works well when you want clean separation between data and behavior. The models hold data and the visitors define behavior. This makes both easier to understand, easier to test, and easier to maintain independently.</p>
<h2 id="heading-when-not-to-use-it">When Not to Use It</h2>
<p>Avoid Visitor when the object hierarchy changes frequently. Every time you add a new type, you must update every existing visitor. In a system where new user types appear regularly, this becomes painful quickly.</p>
<p>It's also not helpful when you only have one or two operations. For simple cases, the overhead of creating visitor interfaces, consumer interfaces, and multiple classes is not worth the benefit.</p>
<p>And avoid it when the operations are tightly coupled to the object's internal state in ways that make sense to keep together. Some behavior naturally belongs on the object itself.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The Visitor Design Pattern solves a problem that most developers only recognize after they've already made a mess of it. You have a family of objects with different types and different data. Operations come in one after another. Without a deliberate structure, those operations spread everywhere: into the models, utility classes, and massive switch statements that nobody wants to touch.</p>
<p>Visitor collects each operation into one focused class. The models stay clean and the operations stay isolated. Adding a new operation means creating one new class. The existing code doesn't change.</p>
<p>In the fintech examples above, we have three entirely different concerns: document export, notifications, and fee calculation. All are handled by handled by focused classes, none of which know anything about each other. The user models don't know about PDF or email or fees. The PdfHandler doesn't know about SMS. The MonthlyFeeCalculator doesn't know about push notifications. Each class has exactly one reason to exist and exactly one reason to change.</p>
<p>That s what a well-applied Visitor pattern looks like in practice. Clean, focused, and genuinely extensible.</p>
<p>Happy coding!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How Clients and Servers Communicate: Full Handbook on HTTP/1.1, HTTP/2, REST, WebSockets, GraphQL, gRPC, and Protocol Buffers ]]>
                </title>
                <description>
                    <![CDATA[ You've built and consumed APIs. You know what a GET request is, what a JSON response looks like, and how to add an Authorization header. You've used REST, maybe tried GraphQL, and perhaps heard of gRP ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-clients-and-servers-communicate-handbook-http-rest-websockets-graphql-grpc-protobuf/</link>
                <guid isPermaLink="false">6a62a069f97a6bd65ce3cd8f</guid>
                
                    <category>
                        <![CDATA[ server ]]>
                    </category>
                
                    <category>
                        <![CDATA[ clients ]]>
                    </category>
                
                    <category>
                        <![CDATA[ networking ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software ]]>
                    </category>
                
                    <category>
                        <![CDATA[ engineering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ gRPC ]]>
                    </category>
                
                    <category>
                        <![CDATA[ http ]]>
                    </category>
                
                    <category>
                        <![CDATA[ http2 ]]>
                    </category>
                
                    <category>
                        <![CDATA[ protobuf ]]>
                    </category>
                
                    <category>
                        <![CDATA[ handbook ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Thu, 23 Jul 2026 23:14:49 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/b44f7067-5398-492a-b1f7-789f73673c34.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>You've built and consumed APIs. You know what a GET request is, what a JSON response looks like, and how to add an Authorization header. You've used REST, maybe tried GraphQL, and perhaps heard of gRPC.</p>
<p>But do you know what actually happens when your application sends a request? What travels through the wire? Why does HTTP/2 make things faster? Why do WebSockets exist when HTTP already works? What makes Protocol Buffers different from JSON at a fundamental level?</p>
<p>And when you're designing a system, how do you decide which communication approach to use?</p>
<p>These are the questions this handbook answers.</p>
<p>This isn't a beginner's guide to APIs. This is a deep dive into how clients and servers actually communicate: the protocols, the trade-offs, the history of why each approach was built, and the engineering thinking behind choosing one over another.</p>
<p>By the end, you won't just know what these technologies are. You'll understand why they exist, how they work at a level that makes you a better engineer, and how to make deliberate architectural decisions about communication in your systems.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#the-foundation-how-two-machines-talk-to-each-other">The Foundation: How Two Machines Talk to Each Other</a></p>
</li>
<li><p><a href="#http11-the-protocol-that-built-the-web">HTTP/1.1: The Protocol That Built the Web</a></p>
</li>
<li><p><a href="#the-problems-http11-could-not-solve">The Problems HTTP/1.1 Could Not Solve</a></p>
</li>
<li><p><a href="#http2-rebuilding-the-foundation">HTTP/2: Rebuilding the Foundation</a></p>
</li>
<li><p><a href="#http3-and-quic-the-next-evolution">HTTP/3 and QUIC: The Next Evolution</a></p>
</li>
<li><p><a href="#data-formats-how-information-is-encoded">Data Formats: How Information Is Encoded</a></p>
</li>
<li><p><a href="#rest-the-architecture-that-took-over-the-world">REST: The Architecture That Took Over the World</a></p>
</li>
<li><p><a href="#the-limits-of-rest">The Limits of REST</a></p>
</li>
<li><p><a href="#graphql-letting-the-client-decide">GraphQL: Letting the Client Decide</a></p>
</li>
<li><p><a href="#websockets-when-http-is-not-enough">WebSockets: When HTTP Is Not Enough</a></p>
</li>
<li><p><a href="#server-sent-events-the-simpler-real-time-option">Server-Sent Events: The Simpler Real-Time Option</a></p>
</li>
<li><p><a href="#protocol-buffers-a-new-language-for-data">Protocol Buffers: A New Language for Data</a></p>
</li>
<li><p><a href="#grpc-remote-procedure-calls-at-scale">gRPC: Remote Procedure Calls at Scale</a></p>
</li>
<li><p><a href="#the-complete-comparison">The Complete Comparison</a></p>
</li>
<li><p><a href="#how-to-choose-the-engineering-decision-framework">How to Choose: The Engineering Decision Framework</a></p>
</li>
<li><p><a href="#conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-the-foundation-how-two-machines-talk-to-each-other">The Foundation: How Two Machines Talk to Each Other</h2>
<p>Before any protocol, data format, or architectural style enters the picture, two machines need to establish a connection. Understanding this foundation makes everything else click.</p>
<h3 id="heading-ip-addresses-and-ports">IP Addresses and Ports</h3>
<p>Every device on a network has an IP address: a unique identifier that works like a postal address. When your application sends a request to <code>api.example.com</code>, the first thing that happens is a DNS lookup, which translates that human-readable name into an IP address like <code>93.184.216.34</code>. That IP address is where the packet is going.</p>
<p>But an IP address alone isn't enough. A single server might be running dozens of different services simultaneously: a web server, a database, an email server, an SSH daemon.</p>
<p>Ports tell the operating system which service should handle the incoming connection. Port 80 is the conventional port for HTTP. Port 443 is for HTTPS. Port 5432 is for PostgreSQL. Port 22 is for SSH. When you call <code>api.example.com/users</code>, you are actually calling <code>api.example.com:443/users</code>. The browser fills in the port automatically.</p>
<h3 id="heading-tcp-the-reliable-foundation">TCP: The Reliable Foundation</h3>
<p>Most web communication runs over TCP (Transmission Control Protocol). TCP is a connection-oriented protocol, which means before any data is exchanged, both parties go through a handshake to establish a connection.</p>
<p>The TCP handshake works in three steps, which is why it's called the three-way handshake:</p>
<pre><code class="language-plaintext">Client                    Server
  |                          |
  |-------- SYN -----------&gt;|   "I want to connect"
  |                          |
  |&lt;------- SYN-ACK --------|   "Okay, I acknowledge. Ready?"
  |                          |
  |-------- ACK -----------&gt;|   "Great, let's go"
  |                          |
  [Connection established]
</code></pre>
<p>SYN stands for synchronize. ACK stands for acknowledge. After these three packets, the connection exists and data can flow.</p>
<p>TCP guarantees three things that make it the foundation of reliable communication:</p>
<ol>
<li><p><strong>Delivery</strong>: if a packet is lost in transit, TCP detects this and retransmits it automatically. The application layer never has to worry about lost packets.</p>
</li>
<li><p><strong>Order</strong>: packets arrive in the same order they were sent. If packets arrive out of order (which happens frequently on real networks), TCP reorders them before delivering them to the application.</p>
</li>
<li><p><strong>Error detection</strong>: every TCP packet includes a checksum. If the data is corrupted in transit, TCP detects and discards the corrupted packet, then requests a retransmission.</p>
</li>
</ol>
<p>This reliability comes at a cost: the overhead of the handshake, the acknowledgment packets, and the retransmission logic.</p>
<p>For many use cases, this cost is worth it. For some (live video streaming, online gaming, DNS lookups), UDP (User Datagram Protocol) is preferred because it sends packets without any of this overhead, accepting some loss in exchange for speed. HTTP/3, which we'll cover later, is built on a protocol that brings reliability to UDP.</p>
<h3 id="heading-tls-encrypting-the-connection">TLS: Encrypting the Connection</h3>
<p>On the modern web, most connections use HTTPS rather than plain HTTP. The S stands for Secure, and the security is provided by TLS (Transport Layer Security), the successor to SSL.</p>
<p>TLS adds an additional handshake on top of the TCP connection. During the TLS handshake:</p>
<ol>
<li><p>The client and the server agree on which version of TLS to use and which encryption algorithms to support</p>
</li>
<li><p>The server presents its digital certificate (issued by a trusted Certificate Authority)</p>
</li>
<li><p>The client verifies the certificate is valid and belongs to the server it intended to reach</p>
</li>
<li><p>They exchange encryption keys using asymmetric cryptography</p>
</li>
<li><p>From that point forward, all communication is encrypted with symmetric encryption</p>
</li>
</ol>
<p>The TLS handshake adds latency. In TLS 1.2, it takes two round trips before any application data can flow. TLS 1.3, released in 2018, reduced this to one round trip, and even supports zero round-trip resumption for returning connections.</p>
<p>Understanding TCP and TLS matters because every protocol we discuss runs on top of them (until HTTP/3, which changes the underlying transport). When people talk about the "overhead" of HTTPS or the "cost" of establishing a connection, they're talking about the time and packets spent on these handshakes before a single byte of your actual request travels.</p>
<h2 id="heading-http11-the-protocol-that-built-the-web">HTTP/1.1: The Protocol That Built the Web</h2>
<p>HTTP (HyperText Transfer Protocol) was invented by Tim Berners-Lee in 1991 to transfer HTML documents between computers. HTTP/1.0 was simple: one request per connection, then the connection closes.</p>
<p>HTTP/1.1, standardized in 1997, brought significant improvements and became the dominant version of HTTP for nearly two decades. It introduced persistent connections (keep connections open across multiple requests), chunked transfer encoding, and more sophisticated caching mechanisms.</p>
<h3 id="heading-how-an-http11-request-works">How an HTTP/1.1 Request Works</h3>
<p>An HTTP request is a text message with a specific structure:</p>
<pre><code class="language-plaintext">POST /api/users HTTP/1.1
Host: api.example.com
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...
Accept: application/json
Content-Length: 45
User-Agent: MyApp/2.0

{"name": "John Smith", "email": "john@example.com"}
</code></pre>
<p>The first line is the request line: the HTTP method (POST), the path (/api/users), and the protocol version.</p>
<p>Below that are the headers: key-value pairs that provide metadata about the request. The host, the content type, the authorization token, what format the client accepts, and how large the body is.</p>
<p>After a blank line comes the body: the actual data being sent.</p>
<p>The server processes this and responds:</p>
<pre><code class="language-plaintext">HTTP/1.1 201 Created
Content-Type: application/json
Location: /api/users/usr_789
Date: Mon, 21 Jul 2026 09:15:00 GMT
Content-Length: 89

{"id": "usr_789", "name": "John Smith", "email": "john@example.com", "created_at": "..."}
</code></pre>
<p>The response has a status line (the protocol version, the status code, and a reason phrase), headers, and a body.</p>
<h3 id="heading-http-methods-and-their-semantics">HTTP Methods and Their Semantics</h3>
<p>HTTP/1.1 defines several methods, each with specific semantics:</p>
<ul>
<li><p><strong>GET</strong> retrieves a resource. A GET request should have no side effects. It shouldn't create or modify anything. It's safe and idempotent, meaning calling it multiple times has the same effect as calling it once.</p>
</li>
<li><p><strong>POST</strong> submits data to create a new resource or trigger an action. It's neither safe nor idempotent: calling POST twice typically creates two resources.</p>
</li>
<li><p><strong>PUT</strong> replaces a resource entirely with the provided data. It's idempotent: calling PUT twice with the same data has the same effect as calling it once.</p>
</li>
<li><p><strong>PATCH</strong> partially updates a resource. Only the fields provided are changed.</p>
</li>
<li><p><strong>DELETE</strong> removes a resource. It's idempotent: deleting something that doesn't exist is still considered successful.</p>
</li>
<li><p><strong>HEAD</strong> is identical to GET but the server only returns headers, not the body. It's used to check if a resource exists or has been modified without downloading the full content.</p>
</li>
<li><p><strong>OPTIONS</strong> asks the server what methods are allowed for a resource. It's used in CORS preflight requests.</p>
</li>
</ul>
<h3 id="heading-status-codes">Status Codes</h3>
<p>HTTP status codes are three-digit numbers grouped into five categories:</p>
<p><strong>1xx Informational</strong> — the server has received the request and is continuing to process it. These are rarely seen in practice outside of specific use cases like HTTP upgrade (used to establish WebSocket connections).</p>
<p><strong>2xx Success</strong> — the request was received, understood, and accepted.</p>
<ul>
<li><p>200 OK: standard success response</p>
</li>
<li><p>201 Created: a new resource was created</p>
</li>
<li><p>204 No Content: success but nothing to return (common for DELETE)</p>
</li>
</ul>
<p><strong>3xx Redirection</strong> — further action is required to complete the request.</p>
<ul>
<li><p>301 Moved Permanently: the resource has a new URL forever</p>
</li>
<li><p>302 Found: temporary redirect</p>
</li>
<li><p>304 Not Modified: the cached version is still valid (used with ETags)</p>
</li>
</ul>
<p><strong>4xx Client Error</strong> — the request contains bad syntax or can't be fulfilled.</p>
<ul>
<li><p>400 Bad Request: the request is malformed</p>
</li>
<li><p>401 Unauthorized: authentication is required (despite the name, it means unauthenticated)</p>
</li>
<li><p>403 Forbidden: authenticated but not authorized to access this resource</p>
</li>
<li><p>404 Not Found: the resource doesn't exist</p>
</li>
<li><p>422 Unprocessable Entity: the request is syntactically valid but semantically wrong (common for validation errors)</p>
</li>
<li><p>429 Too Many Requests: rate limit exceeded</p>
</li>
</ul>
<p><strong>5xx Server Error</strong> — the server failed to fulfill a valid request.</p>
<ul>
<li><p>500 Internal Server Error: something went wrong on the server</p>
</li>
<li><p>502 Bad Gateway: the server received an invalid response from an upstream server</p>
</li>
<li><p>503 Service Unavailable: the server is temporarily unavailable</p>
</li>
<li><p>504 Gateway Timeout: the upstream server did not respond in time</p>
</li>
</ul>
<h3 id="heading-caching-in-http11">Caching in HTTP/1.1</h3>
<p>One of HTTP/1.1's most powerful features is its built-in caching model. Responses can include headers that tell clients and intermediate caches how long to store a response and when to revalidate it.</p>
<ul>
<li><p><code>Cache-Control: max-age=3600</code> tells the client to cache this response for one hour.</p>
</li>
<li><p><code>Cache-Control: no-cache</code> tells the client to always revalidate with the server before using a cached response.</p>
</li>
<li><p><code>Cache-Control: no-store</code> tells the client never to cache this response.</p>
</li>
</ul>
<p><code>ETag</code> is a fingerprint of the response content. When the client makes a subsequent request, it sends the ETag back in an <code>If-None-Match</code> header. If the content hasn't changed, the server responds with 304 Not Modified and no body, saving bandwidth.</p>
<p><code>Last-Modified</code> works similarly: the client sends <code>If-Modified-Since</code> and the server confirms whether the content has changed.</p>
<p>Caching is one of the key reasons REST over HTTP became dominant. GET requests to well-designed REST APIs can be cached at the CDN level, meaning the same response is served to thousands of users without the request ever reaching your origin server.</p>
<h2 id="heading-the-problems-http11-could-not-solve">The Problems HTTP/1.1 Could Not Solve</h2>
<p>HTTP/1.1 served the web well for two decades. But as the web grew more complex, applications more dynamic, and user expectations higher, its architectural limitations became significant performance bottlenecks.</p>
<h3 id="heading-head-of-line-blocking">Head-of-Line Blocking</h3>
<p>HTTP/1.1 processes requests sequentially on a single connection. The server must finish responding to one request before the next one on the same connection begins.</p>
<pre><code class="language-plaintext">Connection 1:
Request 1 (slow database query) -----&gt; [3 seconds] -----&gt; Response 1
Request 2 (fast in-memory read) -----&gt; [waits 3 seconds] -----&gt; Response 2
Request 3 (static file) -----------&gt; [waits 3+ seconds] -----&gt; Response 3
</code></pre>
<p>Request 2 and Request 3 are fast operations. But they're stuck waiting for Request 1 to complete. This is head-of-line blocking: the head of the queue blocks everything behind it.</p>
<p>Browsers worked around this by opening multiple parallel TCP connections to the same server, typically six. But each connection requires its own TCP handshake and TLS negotiation, consuming resources on both the client and server.</p>
<h3 id="heading-verbose-headers-on-every-request">Verbose Headers on Every Request</h3>
<p>Every HTTP/1.1 request sends its complete headers as plain text. Consider a mobile application making fifty requests during a session. On every single request, the following headers are sent in full:</p>
<pre><code class="language-plaintext">Host: api.example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c3JfMTIzIn0...
Content-Type: application/json
Accept: application/json
Accept-Language: en-US,en;q=0.9
Accept-Encoding: gzip, deflate, br
User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)...
</code></pre>
<p>The Authorization header alone, carrying a JWT, can be 400 to 600 bytes. Multiplied by fifty requests, that is 20 to 30 kilobytes of data carrying nothing but headers that haven't changed between requests.</p>
<p>On a 4G mobile connection with limited bandwidth, this is waste. On a 2G connection in a network-constrained environment, it's a significant performance penalty.</p>
<h3 id="heading-no-server-push">No Server Push</h3>
<p>HTTP/1.1 is strictly request-response. The server can't send data until the client asks for it. This fundamental limitation means the server can never proactively inform the client of changes.</p>
<p>For applications requiring real-time updates, short polling became a common workaround: the client sends a request every few seconds asking "has anything changed?" This is inefficient because most polling requests receive a "no, nothing has changed" response, consuming bandwidth and server resources for no purpose.</p>
<p>Long polling was a refinement: the client sends a request and the server holds it open until something changes or a timeout occurs. This reduces unnecessary responses but keeps connections open indefinitely, consuming server resources.</p>
<p>Both are workarounds for a fundamental limitation of HTTP/1.1's request-response model.</p>
<h3 id="heading-inefficient-use-of-connections">Inefficient Use of Connections</h3>
<p>Opening a new TCP connection requires the three-way handshake plus the TLS handshake: a process that can take 200 to 500 milliseconds on a mobile connection.</p>
<p>HTTP/1.1 introduced keep-alive connections to reuse connections across multiple requests, but head-of-line blocking made this only partially effective. Browsers opened multiple connections to compensate, but six parallel connections per domain is both a client limitation and a server resource concern at scale.</p>
<h2 id="heading-http2-rebuilding-the-foundation">HTTP/2: Rebuilding the Foundation</h2>
<p>Google published a protocol called SPDY (pronounced "speedy") in 2009, designed to address HTTP/1.1's performance limitations. SPDY demonstrated that significant improvements were possible without changing the fundamental HTTP semantics. HTTP/2, standardized by the IETF in 2015, was heavily based on SPDY and became the successor to HTTP/1.1.</p>
<p>HTTP/2 doesn't change what you send. From the application developer's perspective, requests still have methods, paths, headers, and bodies. Responses still have status codes, headers, and bodies. What HTTP/2 changes is how all of this is transmitted.</p>
<h3 id="heading-binary-framing-the-core-change">Binary Framing: The Core Change</h3>
<p>HTTP/1.1 is a text protocol. Headers, status lines, and method names are all ASCII text. Machines must parse this text character by character to interpret it.</p>
<p>HTTP/2 is a binary protocol. Every piece of information is encoded as binary frames rather than text. Binary is more compact and significantly faster for machines to parse. Instead of tokenizing a string looking for colons and newlines to separate header names from values, a binary parser reads fixed-length fields directly from memory.</p>
<p>The binary framing layer is the foundation everything else in HTTP/2 is built upon.</p>
<h3 id="heading-multiplexing-many-streams-one-connection">Multiplexing: Many Streams, One Connection</h3>
<p>HTTP/2 introduces the concept of streams. A stream is an independent, bidirectional sequence of frames within a single TCP connection. Multiple streams can exist simultaneously on the same connection.</p>
<pre><code class="language-plaintext">Single TCP connection to api.example.com

Stream 1: GET /user/profile ---------&gt; Response arrives
Stream 2: GET /user/balance ---------&gt; Response arrives
Stream 3: POST /transactions --------&gt; Response arrives
Stream 4: GET /notifications --------&gt; Response arrives

All four streams active simultaneously
No stream waits for any other stream
</code></pre>
<p>This is multiplexing: many independent requests and responses interleaved on the same connection. Head-of-line blocking at the HTTP level is eliminated. A slow request on Stream 1 doesn't prevent Stream 2, 3, or 4 from receiving their responses.</p>
<p>One connection replaces six parallel connections. The TCP handshake and TLS negotiation happen once. Connection overhead drops dramatically.</p>
<h3 id="heading-header-compression-with-hpack">Header Compression with HPACK</h3>
<p>HTTP/2 compresses headers using an algorithm called HPACK specifically designed for HTTP headers.</p>
<p>HPACK works in two ways. First, it maintains a table of previously seen headers. Instead of retransmitting a header that was sent on the previous request, it sends a reference to the table entry: a single integer instead of hundreds of bytes of text.</p>
<p>Second, HPACK uses Huffman encoding for new header values, reducing the size of strings that can't be referenced from the table.</p>
<p>The result: a mobile application sending the same Authorization header on every request transmits it in full on the first request, then sends a one-byte or two-byte reference on every subsequent request. What was 500 bytes of overhead becomes 2 bytes.</p>
<p>Across fifty requests in a session, this eliminates thousands of bytes of redundant header transmission.</p>
<h3 id="heading-stream-prioritization">Stream Prioritization</h3>
<p>HTTP/2 allows clients to assign priority to streams. A browser loading a web page can signal that the CSS file (needed to render anything) is higher priority than the analytics script (not needed for initial render). The server can use these priorities to decide the order in which it sends frames when multiple streams are active.</p>
<p>In practice, stream prioritization has been inconsistently implemented and is being redesigned in HTTP/3.</p>
<h3 id="heading-server-push">Server Push</h3>
<p>HTTP/2 allows the server to proactively send resources to the client without waiting for a request. When a browser requests an HTML file, the server can immediately push the CSS and JavaScript files it knows the browser will need next, before the browser has even parsed the HTML to discover it needs them.</p>
<pre><code class="language-plaintext">Client: GET /index.html
Server: Here is index.html
Server: (push) Here is styles.css — you will need this
Server: (push) Here is app.js — you will need this too
</code></pre>
<p>In practice, server push has had mixed adoption due to implementation complexity and the risk of pushing resources the client already has cached. HTTP/3 is reconsidering how push should work.</p>
<h3 id="heading-http2-and-grpc">HTTP/2 and gRPC</h3>
<p>HTTP/2's multiplexing and persistent connections make it the ideal transport for gRPC. A single HTTP/2 connection can carry many concurrent gRPC calls, including long-running streaming calls that push data continuously. This is why gRPC requires HTTP/2: the features that make gRPC efficient are provided by the transport layer.</p>
<h2 id="heading-http3-and-quic-the-next-evolution">HTTP/3 and QUIC: The Next Evolution</h2>
<p>Even with HTTP/2's improvements, one fundamental problem remained: TCP head-of-line blocking.</p>
<p>HTTP/2 eliminated head-of-line blocking at the HTTP level. Multiple HTTP/2 streams can proceed independently. But all of those streams share a single TCP connection. TCP guarantees ordered delivery of all bytes in a connection. If a single TCP packet is lost, the entire connection stalls while TCP retransmits that packet, even for streams that have nothing to do with the lost packet.</p>
<pre><code class="language-plaintext">HTTP/2 over TCP — packet loss scenario:

Stream 1: data in flight...
Stream 2: data in flight...
Stream 3: packet LOST — TCP retransmission required

Stream 1: STALLED (waiting for TCP retransmission)
Stream 2: STALLED (waiting for TCP retransmission)
Stream 3: retransmission in progress...
</code></pre>
<p>Both streams 1 and 2 are blocked by a packet loss that affected only stream 3. This is TCP head-of-line blocking, and HTTP/2 can't eliminate it because it operates above the TCP layer.</p>
<h3 id="heading-quic-a-new-transport-protocol">QUIC: A New Transport Protocol</h3>
<p>Google developed QUIC (Quick UDP Internet Connections) to solve this problem. QUIC is a new transport protocol built on UDP instead of TCP, designed to provide some very helpful new features:</p>
<ol>
<li><p><strong>Multiplexing without head-of-line blocking:</strong> QUIC understands streams natively. A packet loss in one QUIC stream only stalls that stream. Other streams on the same connection continue flowing freely.</p>
</li>
<li><p><strong>Built-in encryption:</strong> Unlike TLS which runs on top of TCP, QUIC has TLS 1.3 built into the protocol itself. The transport and security layers are integrated, reducing the number of round trips required before data can flow.</p>
</li>
<li><p><strong>Faster connection establishment:</strong> A new QUIC connection requires one round trip before data can flow. For returning connections where a session ticket exists, QUIC can send data in zero round trips (0-RTT).</p>
</li>
<li><p><strong>Connection migration:</strong> A TCP connection is identified by the four-tuple of source IP, source port, destination IP, and destination port. If any of these change (say, a mobile device switches from WiFi to cellular), the TCP connection breaks and must be re-established. QUIC connections are identified by a connection ID that survives network changes, enabling seamless handoff.</p>
</li>
</ol>
<h3 id="heading-http3">HTTP/3</h3>
<p>HTTP/3 is HTTP semantics over QUIC. The request and response model remains the same. Headers, status codes, and methods are all identical. The transport underneath is QUIC instead of TCP.</p>
<p>HTTP/3 is particularly impactful for:</p>
<ol>
<li><p><strong>Mobile networks</strong> where packet loss is more common and devices frequently switch between networks.</p>
</li>
<li><p><strong>High-latency connections</strong> where the reduced handshake round trips save meaningful time.</p>
</li>
<li><p><strong>Applications with many concurrent streams</strong> where TCP head-of-line blocking was a real bottleneck.</p>
</li>
</ol>
<p>As of 2026, HTTP/3 is supported by major browsers, CDNs, and an increasing number of backend servers. Adoption continues to grow.</p>
<h2 id="heading-data-formats-how-information-is-encoded">Data Formats: How Information Is Encoded</h2>
<p>Independent of which protocol carries data, systems need to agree on how data is encoded. The most important formats for API communication are JSON and Protocol Buffers.</p>
<h3 id="heading-json-the-universal-language">JSON: The Universal Language</h3>
<p>JSON (JavaScript Object Notation) was derived from JavaScript syntax and formalized as a standalone data format. Its design philosophy is human readability and simplicity.</p>
<p>A JSON object is a collection of key-value pairs enclosed in curly braces. Keys are always strings. Values can be strings, numbers, booleans, null, arrays, or other objects.</p>
<pre><code class="language-plaintext">{
  "id": "usr_001",
  "name": "John Smith",
  "age": 28,
  "is_verified": true,
  "scores": [98, 87, 92],
  "address": {
    "city": "Lagos",
    "country": "Nigeria"
  }
}
</code></pre>
<p>JSON became the dominant API data format for several reasons. It's human-readable: a developer can look at a JSON response in a browser's developer tools and immediately understand it. It maps naturally to data structures in virtually every programming language. It requires no special tooling or schema definition. And it's flexible: fields can be added or removed without necessarily breaking existing clients.</p>
<h3 id="heading-the-structural-cost-of-json">The Structural Cost of JSON</h3>
<p>JSON's human-readable design comes with a structural cost that becomes significant at scale.</p>
<p>Every field name is a string that travels over the network on every single response. In the example above, the strings <code>"is_verified"</code>, <code>"address"</code>, <code>"country"</code> aren't data. They're labels for data. They consume bytes, they must be tokenized and parsed, and they're repeated on every response for every user.</p>
<p>JSON is a text format, which means it must be parsed from text into the application's native data structures. This parsing isn't free: it requires allocating memory for strings, walking the text byte by byte to find delimiters, and constructing objects from the parsed values.</p>
<p>For a fintech platform with an internal API that returns a 1000-field response and is called by dozens of internal services millions of times per day, the cumulative cost of JSON's verbosity and parsing overhead becomes measurable in bandwidth bills and server CPU time.</p>
<p>JSON also has no formal schema at the network level. There's nothing in the JSON format itself that prevents a backend from changing <code>"account_balance"</code> to <code>"balance"</code>. The change compiles fine. The server deploys. Clients that depend on <code>"account_balance"</code> break silently at runtime.</p>
<h3 id="heading-xml-the-predecessor">XML: The Predecessor</h3>
<p>Before JSON, XML (eXtensible Markup Language) was the dominant data format for web services (used in SOAP, the predecessor to REST). XML is more verbose than JSON, wrapping every value in opening and closing tags:</p>
<pre><code class="language-plaintext">&lt;user&gt;
  &lt;id&gt;usr_001&lt;/id&gt;
  &lt;name&gt;John Smith&lt;/name&gt;
  &lt;age&gt;28&lt;/age&gt;
  &lt;is_verified&gt;true&lt;/is_verified&gt;
&lt;/user&gt;
</code></pre>
<p>XML has advantages: it supports schemas (XSD), namespaces, and complex document structures. It's still used in enterprise systems, document formats (DOCX, SVG, RSS), and configuration files. But for API communication, JSON's simplicity won.</p>
<h2 id="heading-rest-the-architecture-that-took-over-the-world">REST: The Architecture That Took Over the World</h2>
<p>REST (Representational State Transfer) was defined by Roy Fielding in his doctoral dissertation in 2000. Fielding was one of the principal authors of the HTTP specification, and REST emerged from his analysis of what made HTTP architecturally successful.</p>
<p>REST isn't a protocol. It's an architectural style: a set of constraints that, when applied to a distributed system, produce desired properties including scalability, simplicity, and modifiability.</p>
<h3 id="heading-the-six-rest-constraints">The Six REST Constraints</h3>
<p>Fielding defined six constraints that define a RESTful architecture. Most APIs described as "REST" implement a subset of these, which is why the term "RESTful" covers a wide spectrum.</p>
<p><strong>1. Client-Server:</strong> The client and server are separate concerns. The client manages the user interface. The server manages data storage and business logic. They evolve independently. This separation allows each to scale and change without affecting the other.</p>
<p><strong>2. Stateless:</strong> Each request from the client to the server must contain all the information needed to understand and process the request. The server doesn't store any session state between requests. If a client needs to be authenticated, the authentication information (typically a token) travels with every request.</p>
<p>Statelessness is what makes REST APIs horizontally scalable. Any server instance can handle any request because no session state needs to be co-located with the request. Load balancers can route requests freely.</p>
<p><strong>3. Cacheable:</strong> Responses must define themselves as cacheable or non-cacheable. If a response is cacheable, clients and intermediate layers (CDN, reverse proxies) can store and reuse the response without hitting the server.</p>
<p>Caching is one of the most powerful properties of REST. A well-designed REST API can serve millions of identical GET requests from CDN cache, with only a fraction ever reaching the origin server.</p>
<p><strong>4. Uniform Interface:</strong> The interface between client and server is standardized. Resources are identified by URIs. Resources are manipulated through representations. Messages are self-descriptive. This uniformity is what makes REST APIs universally accessible: a developer in any language can call a REST API using standard HTTP tooling.</p>
<p><strong>5. Layered System:</strong> The client doesn't need to know whether it's connected directly to the server or to an intermediary (load balancer, CDN, API gateway, caching proxy). Each layer only sees the layer it is interacting with. This enables transparent scaling and security.</p>
<p><strong>6. Code on Demand (Optional):</strong> Servers can extend client functionality by sending executable code (JavaScript). This is the only optional constraint and is the basis for how browsers work, but rarely relevant to API design.</p>
<h3 id="heading-resources-and-uris">Resources and URIs</h3>
<p>The central concept in REST is the resource. A resource is any piece of information that can be named, like a user, an order, a product, or a collection of transactions.</p>
<p>Resources are identified by URIs (Uniform Resource Identifiers). The URI identifies what the resource is, not what to do with it. The HTTP method expresses the operation.</p>
<pre><code class="language-plaintext">GET    /users           — retrieve all users
GET    /users/123       — retrieve user 123
POST   /users           — create a new user
PUT    /users/123       — replace user 123 entirely
PATCH  /users/123       — partially update user 123
DELETE /users/123       — delete user 123

GET    /users/123/orders        — orders belonging to user 123
POST   /users/123/orders        — create an order for user 123
GET    /users/123/orders/456    — order 456 belonging to user 123
</code></pre>
<p>The URI structure forms a hierarchy that reflects the relationships between resources. This makes APIs predictable: a developer who understands the resource model can guess the correct URIs.</p>
<h3 id="heading-why-rest-won">Why REST Won</h3>
<p>REST became the dominant architectural style for web APIs for reasons that go beyond technical merit:</p>
<p><strong>Universal accessibility:</strong> Any device, any language, any framework that can make an HTTP request can call a REST API. There's no special client library needed.</p>
<p><strong>HTTP alignment:</strong> REST leverages HTTP's existing infrastructure. CDN caching works for free. Load balancers understand HTTP. Monitoring tools speak HTTP. The entire ecosystem is built around HTTP semantics.</p>
<p><strong>Simplicity:</strong> A REST API can be designed, documented, and consumed with minimal tooling. A developer can test endpoints in a browser or with <code>curl</code> immediately.</p>
<p><strong>Developer experience:</strong> JSON over HTTP is something every web developer already understands. The learning curve is essentially zero.</p>
<p><strong>Ecosystem maturity:</strong> OpenAPI/Swagger provides standardized documentation. Postman provides testing. Every programming language has robust HTTP client libraries.</p>
<h3 id="heading-the-limits-of-rest">The Limits of REST</h3>
<p>REST's success is real. But so are its limitations, and understanding them is essential to knowing when to reach for something else.</p>
<h4 id="heading-overfetching-getting-more-than-you-need">Overfetching: Getting More Than You Need</h4>
<p>A REST endpoint returns a fixed shape of data. The <code>/users/123</code> endpoint returns the full user object: name, email, phone, address, preferences, account status, and thirty other fields.</p>
<p>A mobile screen that displays only the user's name and avatar must receive all of those fields to use two of them. The rest is waste: wasted bandwidth, serialization on the server, and deserialization on the client.</p>
<p>On a constrained mobile connection, this overfetching isn't just inefficient. It's a measurable degradation of user experience.</p>
<h4 id="heading-underfetching-not-getting-enough-at-once">Underfetching: Not Getting Enough at Once</h4>
<p>The opposite problem is equally common. A screen needs data from multiple resources: the user's profile, their recent orders, their notification count, and their account balance.</p>
<p>A REST API typically models these as separate endpoints. Loading this screen requires four separate HTTP requests, each with its own round-trip latency.</p>
<pre><code class="language-plaintext">GET /users/123         → profile data
GET /users/123/orders  → orders data
GET /notifications?user=123 → notification count
GET /accounts/123/balance   → balance data
</code></pre>
<p>Four sequential round trips. On a 200ms latency connection, that's 800ms of network time before the screen can render completely.</p>
<h4 id="heading-the-n1-problem">The N+1 Problem</h4>
<p>A common variant of underfetching: you fetch a list of resources, then must fetch additional data for each item in the list.</p>
<pre><code class="language-plaintext">GET /orders            → returns 20 orders (each with a user_id)
GET /users/1           → user for order 1
GET /users/2           → user for order 2
...
GET /users/20          → user for order 20
</code></pre>
<p>21 requests to load one screen. This pattern appears constantly in REST APIs and is addressed in various ways: including nested data in responses, adding query parameters to expand related resources, or creating purpose-built endpoints for specific screens.</p>
<p>All of these workarounds create tension: the API becomes less general as it's optimized for specific client needs.</p>
<h4 id="heading-no-native-real-time-support">No Native Real-Time Support</h4>
<p>REST is request-response. The client initiates every interaction. The server can never proactively push data.</p>
<p>Real-time features like live notifications, collaborative editing, and streaming data require either polling (inefficient), long-polling (complex), or a separate real-time technology bolted alongside the REST API.</p>
<h4 id="heading-the-documentation-drift-problem">The Documentation Drift Problem</h4>
<p>A REST API contract lives in documentation. Nothing in the HTTP protocol enforces that the documentation accurately reflects the API's actual behavior. As APIs evolve, documentation falls behind. Fields are renamed, types change, endpoints are deprecated. Clients built against outdated documentation break.</p>
<p>This isn't a theoretical problem. It's a daily reality in engineering teams where the backend and frontend evolve at different speeds.</p>
<h2 id="heading-graphql-letting-the-client-decide">GraphQL: Letting the Client Decide</h2>
<p>GraphQL was developed at Facebook starting in 2012 and open-sourced in 2015. Facebook built it to solve a specific problem: their mobile app needed to fetch complex, interconnected social data from a REST API, and the resulting overfetching and multiple round trips were degrading performance on mobile devices.</p>
<p>GraphQL's core insight is simple and radical: instead of the server deciding what data to return, let the client specify exactly what it needs.</p>
<h3 id="heading-the-query-language">The Query Language</h3>
<p>GraphQL is both a query language for APIs and a runtime for executing those queries. Rather than calling different endpoints for different data, all GraphQL requests go to a single endpoint (typically <code>/graphql</code>) and include a query that describes precisely what data is needed.</p>
<p>A GraphQL query for a user profile screen:</p>
<pre><code class="language-plaintext">query UserProfile {
  user(id: "usr_123") {
    name
    avatarUrl
    recentOrders(limit: 3) {
      id
      total
      status
      createdAt
    }
    notificationCount
  }
}
</code></pre>
<p>The response contains exactly and only the fields requested. Nothing more. If the client needs only <code>name</code> and <code>avatarUrl</code>, it requests only those two fields. The response contains only two fields.</p>
<h3 id="heading-mutations-and-subscriptions">Mutations and Subscriptions</h3>
<p>GraphQL has three operation types:</p>
<ol>
<li><p><strong>Queries</strong> fetch data. They're the GraphQL equivalent of GET requests.</p>
</li>
<li><p><strong>Mutations</strong> modify data: creating, updating, or deleting resources. They're the GraphQL equivalent of POST, PUT, PATCH, and DELETE.</p>
</li>
<li><p><strong>Subscriptions</strong> establish a persistent connection and push data in real-time when specified events occur. A subscription to <code>orderStatusChanged</code> receives a push every time any order's status changes. This is GraphQL's real-time capability, typically implemented over WebSockets.</p>
</li>
</ol>
<h3 id="heading-the-schema">The Schema</h3>
<p>Every GraphQL API is defined by a schema written in the Schema Definition Language (SDL). The schema declares every type, query, mutation, and subscription the API supports.</p>
<pre><code class="language-plaintext">type User {
  id: ID!
  name: String!
  email: String!
  orders: [Order!]!
  notificationCount: Int!
}

type Order {
  id: ID!
  total: Float!
  status: OrderStatus!
  createdAt: String!
}

enum OrderStatus {
  PENDING
  PROCESSING
  SHIPPED
  DELIVERED
}

type Query {
  user(id: ID!): User
  orders(userId: ID!, limit: Int): [Order!]!
}

type Mutation {
  createOrder(userId: ID!, items: [OrderItemInput!]!): Order!
}
</code></pre>
<p>The schema is introspectable: clients can query the schema itself to discover what types and operations are available. This enables powerful tooling: GraphQL IDEs can autocomplete queries, validate them against the schema before sending, and display documentation inline.</p>
<h3 id="heading-where-graphql-wins">Where GraphQL Wins</h3>
<p><strong>Precise data fetching:</strong> Clients request exactly what they need. Overfetching is eliminated by design.</p>
<p><strong>Single round trip for complex data:</strong> Data from multiple resources is fetched in a single request. The N+1 problem is solved at the query level rather than requiring the client to make multiple requests.</p>
<p><strong>Strongly typed schema:</strong> The schema is the contract. Clients can validate their queries against it at build time. Type mismatches are caught before deployment.</p>
<p><strong>Frontend agility:</strong> Frontend teams can evolve their data requirements without asking backend teams to create new endpoints. New screens, data combinations, and features are all handled by writing a new query.</p>
<p><strong>Excellent tooling:</strong> GraphiQL and Apollo Studio provide interactive schema exploration, query building, and performance analysis.</p>
<h3 id="heading-where-graphql-struggles">Where GraphQL Struggles</h3>
<p><strong>Query complexity:</strong> A malicious or poorly written query can request enormous amounts of nested data. A query that fetches every user, each user's orders, each order's items, and each item's product details can bring a server to its knees.</p>
<p>REST endpoints can be individually optimized. GraphQL requires query complexity analysis, depth limiting, and rate limiting to protect the server.</p>
<p><strong>Caching is harder:</strong> REST GET requests are cacheable at the HTTP level by default. GraphQL queries all go through POST requests to a single endpoint, breaking standard HTTP caching. Clients must implement their own caching (Apollo Client does this), but CDN-level caching is essentially unavailable for dynamic queries.</p>
<p><strong>Over-engineering simple APIs:</strong> If your API is straightforward CRUD operations with no complex data relationships and no mobile clients with aggressive data constraints, GraphQL's added setup cost exceeds its benefit.</p>
<p><strong>Real-time at scale is complex:</strong> GraphQL subscriptions work, but scaling WebSocket connections for thousands of concurrent subscribers is infrastructure-intensive and requires careful architecture.</p>
<p><strong>Error handling is non-standard:</strong> A GraphQL request can partially succeed: some fields resolve successfully while others fail. The response includes both data and errors simultaneously. Handling this gracefully requires more nuanced error handling logic than a simple HTTP status code.</p>
<h2 id="heading-websockets-when-http-is-not-enough">WebSockets: When HTTP Is Not Enough</h2>
<p>HTTP, in all its versions, is fundamentally request-response. The client speaks first. The server responds. The conversation ends. Even with HTTP/2's server push, the client initiates every new exchange.</p>
<p>But some applications genuinely need both sides to be able to speak at any moment, without waiting for the other to ask first. For example, a chat application where both parties send messages freely. A live collaborative document where every keystroke is broadcast to co-editors. An online game where the server pushes state updates as they happen and the client sends actions continuously.</p>
<p>For these cases, WebSockets provide a fundamentally different communication model.</p>
<h3 id="heading-the-websocket-handshake">The WebSocket Handshake</h3>
<p>A WebSocket connection starts as an HTTP request and then upgrades to a WebSocket connection. This upgrade mechanism means WebSockets work through existing HTTP infrastructure (firewalls, proxies, load balancers) without requiring special configuration.</p>
<p>The upgrade request:</p>
<pre><code class="language-plaintext">GET /chat HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
</code></pre>
<p>The server confirms the upgrade:</p>
<pre><code class="language-plaintext">HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
</code></pre>
<p>Status code 101 means "Switching Protocols." From this point forward, the HTTP connection is replaced by a WebSocket connection. The protocol has changed. HTTP headers, status codes, and methods no longer apply.</p>
<h3 id="heading-full-duplex-persistent-communication">Full-Duplex, Persistent Communication</h3>
<p>The WebSocket connection is:</p>
<ul>
<li><p><strong>Full-duplex:</strong> both the client and server can send messages at any time, simultaneously, without waiting for the other to finish.</p>
</li>
<li><p><strong>Persistent:</strong> the connection stays open until explicitly closed by either party or until a network interruption occurs.</p>
</li>
<li><p><strong>Low overhead:</strong> once established, WebSocket messages have minimal framing overhead compared to HTTP. A small WebSocket message may have only 2 to 10 bytes of overhead, versus potentially hundreds of bytes of HTTP headers.</p>
</li>
</ul>
<pre><code class="language-plaintext">WebSocket connection open

Client: "Hello, I'm user 123"
Server: "Welcome, user 123"
Server: "User 456 just sent you a message: Hey!"
Client: "Thanks, here's my reply: Hi there!"
Server: "New notification: your payment was confirmed"
Client: "Great, show me my balance"
Server: "Your balance is NGN 500,000"
Server: "Another notification: transfer from user 789 received"

[Both sides communicate freely, at any time, simultaneously]
</code></pre>
<h3 id="heading-where-websockets-win">Where WebSockets Win</h3>
<p><strong>True real-time bidirectional communication</strong>: Applications where both client and server need to send messages at unpredictable times and at high frequency. For example, chat, live collaboration, multiplayer games, financial trading terminals.</p>
<p><strong>Low-latency messaging:</strong> Once the connection is established, message round-trip times can be in the single-digit milliseconds, limited only by network latency rather than connection setup overhead.</p>
<p><strong>Native browser support:</strong> The WebSocket API is built into every modern browser. No libraries are needed for the fundamental connection.</p>
<p><strong>Event-driven architecture on the client:</strong> WebSocket events (message, close, error) map naturally to event-driven client code.</p>
<h3 id="heading-where-websockets-struggle">Where WebSockets Struggle</h3>
<p><strong>Stateful connections:</strong> Each WebSocket connection must be maintained by a specific server instance. When scaling horizontally, a client connected to Server A can't receive messages from Server B without a shared pub/sub layer (like Redis) that all server instances publish to and subscribe from. This adds infrastructure complexity.</p>
<p><strong>No built-in request-response correlation:</strong> WebSockets are a message stream. If you send a message and expect a response, there's no built-in mechanism to correlate which response corresponds to which request. You have to build this yourself.</p>
<p><strong>No schema or contract:</strong> WebSockets send raw text or binary. The format of messages is defined entirely by the application. Two systems communicating over WebSockets must agree on message format out of band, in documentation, and there's nothing to enforce it at the connection level.</p>
<p><strong>Firewall and proxy complications:</strong> Some corporate networks and older proxies don't support the HTTP upgrade mechanism correctly, breaking WebSocket connections. This is less common than it was but still occurs in enterprise environments.</p>
<p><strong>Reconnection must be handled manually:</strong> WebSocket connections can drop due to network instability. Applications must implement reconnection logic, including managing state across reconnections.</p>
<h2 id="heading-server-sent-events-the-simpler-real-time-option">Server-Sent Events: The Simpler Real-Time Option</h2>
<p>Between REST's pure request-response and WebSocket's full bidirectional communication lies a middle option that most developers overlook: Server-Sent Events (SSE).</p>
<p>SSE establishes a one-directional persistent connection: the server pushes data to the client over a regular HTTP connection, and the client listens. The client can't send data back through the same connection.</p>
<h3 id="heading-how-sse-works">How SSE Works</h3>
<p>The client makes a standard HTTP GET request with an <code>Accept: text/event-stream</code> header:</p>
<pre><code class="language-plaintext">GET /notifications HTTP/1.1
Host: api.example.com
Accept: text/event-stream
Authorization: Bearer token123
</code></pre>
<p>The server responds with a 200 OK and keeps the connection open, periodically sending events:</p>
<pre><code class="language-plaintext">HTTP/1.1 200 OK
Content-Type: text/event-stream
Cache-Control: no-cache

data: {"type": "balance_update", "balance": 500000}

data: {"type": "transaction", "id": "txn_001", "amount": -5000}

event: notification
data: {"message": "Your transfer has been confirmed"}

id: 42
data: {"type": "order_status", "status": "shipped"}
</code></pre>
<p>Each event is separated by a blank line. Events can include a <code>data</code> field, an optional <code>event</code> type, and an optional <code>id</code> for resumability.</p>
<h3 id="heading-automatic-reconnection">Automatic Reconnection</h3>
<p>One of SSE's most practical features is automatic reconnection. If the connection drops, the browser automatically reconnects, sending the last received event ID in a <code>Last-Event-ID</code> header. The server can resume from that point, ensuring no events are missed.</p>
<h3 id="heading-where-sse-wins">Where SSE Wins</h3>
<p><strong>Simplicity:</strong> SSE works over plain HTTP. There's no protocol upgrade needed, and no special infrastructure. It works through every HTTP/2 connection, load balancer, and CDN that supports streaming.</p>
<p><strong>Native browser support:</strong> The <code>EventSource</code> API is built into every modern browser. Automatic reconnection is built in.</p>
<p><strong>Perfect for one-directional feeds:</strong> Live dashboards, notification streams, news feeds, real-time analytics, server logs: any scenario where the server pushes a continuous stream of updates and the client only reads.</p>
<p><strong>HTTP/2 multiplexing:</strong> Over HTTP/2, multiple SSE connections can share a single TCP connection. The browser connection limit that affected SSE over HTTP/1.1 doesn't apply.</p>
<p><strong>Natural fit for existing infrastructure:</strong> SSE responses are just HTTP responses. Existing load balancers, authentication middleware, and monitoring tools work without modification.</p>
<h3 id="heading-where-sse-struggles">Where SSE Struggles</h3>
<p><strong>One direction only:</strong> The client can't send data back through the SSE connection. For bidirectional scenarios, SSE isn't sufficient on its own.</p>
<p><strong>Text only (natively):</strong> SSE events are text. Binary data must be base64-encoded, adding overhead.</p>
<p><strong>No native support in all environments.</strong> SSE is a browser API. In other environments (mobile apps, server-to-server), it requires an HTTP client configured to handle streaming responses.</p>
<h3 id="heading-sse-vs-websockets-the-decision">SSE vs WebSockets: The Decision</h3>
<p>Choose SSE when the server pushes data and the client only reads: notifications, live feeds, dashboards, or streaming responses from an AI model. SSE is simpler, works over plain HTTP, and has automatic reconnection built in.</p>
<p>Choose WebSockets when both the client and server need to send messages freely and simultaneously: chat, collaborative editing, and games. The added complexity of WebSockets is justified when you genuinely need bidirectional communication.</p>
<h2 id="heading-protocol-buffers-a-new-language-for-data">Protocol Buffers: A New Language for Data</h2>
<p>Protocol Buffers (protobuf) is a binary serialization format developed by Google. Where JSON encodes data as human-readable text, protobuf encodes data as compact binary. This single difference has cascading implications for payload size, parsing speed, type safety, and schema enforcement.</p>
<h3 id="heading-the-schema-first-approach">The Schema-First Approach</h3>
<p>Unlike JSON, where you simply start writing key-value pairs, protobuf requires defining a schema first. You describe your data structures in a <code>.proto</code> file using Protocol Buffer Language, a language-agnostic schema definition language.</p>
<p>The schema definition:</p>
<pre><code class="language-plaintext">syntax = "proto3";

message User {
  string id = 1;
  string name = 2;
  string email = 3;
  double balance = 4;
  bool is_verified = 5;
  int32 kyc_level = 6;
}

message Order {
  string id = 1;
  string user_id = 2;
  double total = 3;
  string status = 4;
  int64 created_at = 5;
}
</code></pre>
<p>Each field has a name and a type, as in any structured data format. But it also has a field number: the small integer after the equals sign. This field number is the key to protobuf's efficiency.</p>
<h3 id="heading-binary-encoding-why-field-numbers-matter">Binary Encoding: Why Field Numbers Matter</h3>
<p>When protobuf encodes data to binary, field names don't appear in the output. Instead, only the field number and the encoded value are written. Field 1 (id) becomes a tag byte indicating "field 1, type string" followed by the string's length and bytes. Field 4 (balance) becomes a tag byte indicating "field 4, type 64-bit float" followed by eight bytes of IEEE 754 double-precision float.</p>
<p>No <code>"id":</code> string, <code>"balance":</code> string, quotation marks, colons, or braces. Just field tags and values in a compact binary stream.</p>
<p>The same user object that occupies approximately 100 bytes in JSON occupies approximately 35 bytes in protobuf. For a 1000-field enterprise API response called millions of times per day, this difference translates directly to reduced bandwidth consumption and infrastructure cost.</p>
<p>Parsing binary is also fundamentally faster than parsing text. A binary parser reads a fixed-length tag, determines the type and length of the following value, reads that value, and moves to the next field. A JSON parser must tokenize a text stream character by character, handle escape sequences, infer types from value format, and construct a dynamic object from parsed key-value pairs.</p>
<p>On constrained devices or in high-throughput server-to-server communication, this parsing speed difference is meaningful.</p>
<h3 id="heading-code-generation-the-contract-comes-alive">Code Generation: The Contract Comes Alive</h3>
<p>The <code>.proto</code> schema file is the input to the <code>protoc</code> compiler. This compiler generates data classes in any supported language from the same schema definition.</p>
<p>The same <code>user.proto</code> file generates:</p>
<ul>
<li><p>A <code>User</code> class in Go for the backend server</p>
</li>
<li><p>A <code>User</code> class in Dart for the Flutter client</p>
</li>
<li><p>A <code>User</code> class in Python for the data processing service</p>
</li>
<li><p>A <code>User</code> class in TypeScript for the web frontend</p>
</li>
</ul>
<p>Every generated class has typed fields, serialization/deserialization methods, and equality comparison. There's no manual JSON parsing, type casting, or risk of field name typos. The compiler guarantees that every language's representation of a <code>User</code> is identical.</p>
<p>When the schema changes — a new field is added or a field is removed, for example — every team regenerates their classes. If the change is breaking (a required field removed or a type changed in an incompatible way), the compiler reports errors in every affected codebase. The problem is caught before any code reaches production.</p>
<h3 id="heading-schema-evolution-rules">Schema Evolution Rules</h3>
<p>Protobuf's field number system enables backward-compatible schema evolution. Because fields are identified by number rather than name, the following changes are safe:</p>
<ul>
<li><p>Adding a new field with a new number is always safe. Existing clients ignore fields they don't recognize. New clients receive the new field.</p>
</li>
<li><p>Removing a field by marking it as reserved is safe. Existing encoded data that contains the removed field is simply ignored when decoded. The field number must be marked reserved to prevent its reuse.</p>
</li>
<li><p>Renaming a field is safe. Names aren't encoded. Only the number matters at the binary level.</p>
</li>
<li><p>Changing a field's type in incompatible ways is unsafe and breaks existing encoded data.</p>
</li>
</ul>
<p>This evolution model means protobuf schemas can grow over time without coordinated updates across all clients and servers.</p>
<h3 id="heading-trade-offs">Trade-offs</h3>
<p>Protobuf's efficiency comes with costs that make it inappropriate for all contexts.</p>
<p>Binary data isn't human-readable. You can't open a protobuf response in a browser's developer tools and see what it contains. Debugging requires either decoding the binary with the schema or using specialized tools.</p>
<p>Protobuf also requires tooling. Every consumer of a protobuf-encoded API needs the schema and a protobuf library to decode it. For public APIs consumed by unknown third parties, this is a significant barrier. JSON requires nothing: every programming environment can parse it with built-in libraries.</p>
<p>Schema changes require coordination. When a schema changes, every consumer must update. For internal systems where you control all consumers, this is manageable. For public APIs, it requires versioning and migration strategies.</p>
<h2 id="heading-grpc-remote-procedure-calls-at-scale">gRPC: Remote Procedure Calls at Scale</h2>
<p>gRPC combines Protocol Buffers with HTTP/2 and Remote Procedure Call semantics to produce a framework for service-to-service communication that is faster, more structured, and more powerful than REST for specific use cases.</p>
<h3 id="heading-remote-procedure-calls-the-core-concept">Remote Procedure Calls: The Core Concept</h3>
<p>A Remote Procedure Call (RPC) framework makes calling a function on a remote server feel like calling a local function. Instead of constructing an HTTP request, serializing a body, parsing a response, and handling status codes, you call a function with typed arguments and receive a typed return value. The network communication is abstracted away.</p>
<pre><code class="language-plaintext">// Without RPC (manual REST)
const response = await http.post('/users', headers: {...}, body: json.encode(data));
const user = User.fromJson(json.decode(response.body));

// With RPC (gRPC)
final user = await userService.createUser(CreateUserRequest(name: "John", email: "john@example.com"));
</code></pre>
<p>The second form is simpler, type-safe, and requires no knowledge of HTTP methods, endpoints, or serialization formats.</p>
<h3 id="heading-the-four-communication-patterns">The Four Communication Patterns</h3>
<p>gRPC's most significant advantage over REST is its support for four distinct communication patterns, all defined in the same <code>.proto</code> schema and accessible through the same generated client.</p>
<p><strong>Unary RPC</strong> is the familiar request-response pattern. One request and one response. It's equivalent to a REST API call.</p>
<pre><code class="language-plaintext">Client ——— LoginRequest ——→ Server
Client ←—— LoginResponse —— Server
</code></pre>
<p><strong>Server Streaming RPC</strong> sends one request and receives a continuous stream of responses. The server pushes messages as they become available without the client needing to request each one.</p>
<pre><code class="language-plaintext">Client ——— WatchBalanceRequest ——→ Server
Client ←— BalanceResponse ———————— Server (balance: 500,000)
Client ←— BalanceResponse ———————— Server (balance: 495,000)
Client ←— BalanceResponse ———————— Server (balance: 1,000,000)
[Stream stays open, server pushes on every change]
</code></pre>
<p><strong>Client Streaming RPC</strong> sends a stream of messages to the server and receives one response at the end. The server processes all received messages and responds once.</p>
<pre><code class="language-plaintext">Client ——— DocumentChunk 1 ——→ Server
Client ——— DocumentChunk 2 ——→ Server
Client ——— DocumentChunk 3 ——→ Server
Client ←————— UploadResponse —— Server (all chunks processed)
</code></pre>
<p><strong>Bidirectional Streaming RPC</strong> allows both client and server to send streams of messages simultaneously, in any order.</p>
<pre><code class="language-plaintext">Client ——— ChatMessage ——→ Server
Server ←— ChatMessage ——— Client
Client ——— ChatMessage ——→ Server
Server ←— ChatMessage ——— Client  (server-initiated)
[Both sides communicate freely and simultaneously]
</code></pre>
<h3 id="heading-why-http2-and-protobuf-make-grpc-efficient">Why HTTP/2 and Protobuf Make gRPC Efficient</h3>
<p>gRPC's efficiency comes from the combination of its two underlying technologies working together.</p>
<p>HTTP/2's multiplexed persistent connections mean many concurrent gRPC calls, including long-running streaming calls, share a single connection. There's no connection setup overhead per call. Multiple streams proceed in parallel without blocking each other.</p>
<p>Protocol Buffer's binary encoding means payloads are compact and parsing is fast. A high-frequency service-to-service call that would transmit 100 bytes of JSON transmits 35 bytes of protobuf. At thousands of calls per second between microservices, this difference is significant.</p>
<p>The generated clients eliminate all serialization and deserialization code. The schema enforces that client and server agree on the contract. Breaking changes are caught by the compiler.</p>
<h3 id="heading-the-organizational-contract">The Organizational Contract</h3>
<p>In organizations using gRPC at scale, <code>.proto</code> files live in a dedicated repository separate from any individual service. This repository is the single source of truth for every service contract.</p>
<p>When an engineer wants to add a new field to an API, they open a pull request in the proto repository. Engineers from every affected team review it. The change is discussed, refined, and approved before any implementation begins. When it merges, every team regenerates their clients. Changes that break existing behavior are caught in code review, not in production.</p>
<p>This governance model transforms API evolution from a coordination problem into a code review process.</p>
<h3 id="heading-grpcs-limitations">gRPC's Limitations</h3>
<p>gRPC doesn't work natively in web browsers. Browsers can't directly make HTTP/2 requests with the necessary control required for gRPC. A proxy layer (gRPC-Web) is required to translate between gRPC-Web's browser-compatible format and standard gRPC. This adds infrastructure complexity and limits gRPC's applicability for browser-based clients.</p>
<p>gRPC also requires HTTP/2. Environments that don't support HTTP/2 can't use gRPC.</p>
<p>Binary encoding makes debugging harder as well. Inspecting gRPC traffic requires specialized tools and access to the proto schema.</p>
<p>For public APIs consumed by third-party developers, gRPC's tooling requirements are a higher barrier than REST's universally accessible JSON over HTTP.</p>
<h2 id="heading-the-complete-comparison">The Complete Comparison</h2>
<table>
<thead>
<tr>
<th></th>
<th>HTTP/1.1</th>
<th>HTTP/2</th>
<th>REST</th>
<th>GraphQL</th>
<th>WebSockets</th>
<th>SSE</th>
<th>gRPC</th>
</tr>
</thead>
<tbody><tr>
<td>Protocol</td>
<td>HTTP/1.1</td>
<td>HTTP/2</td>
<td>HTTP/1.1 or 2</td>
<td>HTTP/1.1 or 2</td>
<td>WebSocket</td>
<td>HTTP</td>
<td>HTTP/2</td>
</tr>
<tr>
<td>Data format</td>
<td>Any</td>
<td>Any</td>
<td>JSON (typical)</td>
<td>JSON</td>
<td>Any</td>
<td>Text</td>
<td>Protobuf (binary)</td>
</tr>
<tr>
<td>Communication</td>
<td>Request-Response</td>
<td>Request-Response</td>
<td>Request-Response</td>
<td>Request-Response + Subscriptions</td>
<td>Bidirectional</td>
<td>Server to Client</td>
<td>All four patterns</td>
</tr>
<tr>
<td>Contract</td>
<td>None</td>
<td>None</td>
<td>Documentation</td>
<td>Schema (SDL)</td>
<td>None</td>
<td>None</td>
<td>.proto file</td>
</tr>
<tr>
<td>Code generation</td>
<td>No</td>
<td>No</td>
<td>Optional</td>
<td>Optional</td>
<td>No</td>
<td>No</td>
<td>Mandatory</td>
</tr>
<tr>
<td>Real-time</td>
<td>No</td>
<td>Limited (push)</td>
<td>No (polling)</td>
<td>Subscriptions</td>
<td>Yes</td>
<td>Yes (one-way)</td>
<td>Yes (built-in)</td>
</tr>
<tr>
<td>Browser native</td>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
<td>Yes</td>
<td>No (needs proxy)</td>
</tr>
<tr>
<td>Caching</td>
<td>Excellent</td>
<td>Excellent</td>
<td>Excellent</td>
<td>Difficult</td>
<td>Not applicable</td>
<td>Not applicable</td>
<td>Not applicable</td>
</tr>
<tr>
<td>Payload size</td>
<td>Medium</td>
<td>Medium</td>
<td>Medium (JSON)</td>
<td>Medium (JSON)</td>
<td>Low overhead</td>
<td>Low overhead</td>
<td>Small (binary)</td>
</tr>
<tr>
<td>Human readable</td>
<td>Yes</td>
<td>No (binary frames)</td>
<td>Yes</td>
<td>Yes</td>
<td>Depends</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td>Schema enforcement</td>
<td>None</td>
<td>None</td>
<td>None</td>
<td>Compile-time</td>
<td>None</td>
<td>None</td>
<td>Compile-time</td>
</tr>
</tbody></table>
<hr>
<h2 id="heading-how-to-choose-the-engineering-decision-framework">How to Choose: The Engineering Decision Framework</h2>
<p>No single communication approach is universally best. Each exists because it solves specific problems better than the alternatives. The engineering decision involves matching the tool to your requirements.</p>
<h3 id="heading-when-to-use-rest">When to Use REST</h3>
<p>Use REST when the API is public or consumed by third parties. REST's universal accessibility makes it the only reasonable choice for public APIs. Any developer in any language can call a REST API with standard HTTP tools. There are no schema files, generated clients, or special libraries.</p>
<p>REST is also a good fit when caching is a priority. REST GET responses can be cached at every layer: CDN, reverse proxy, and browser. For content that doesn't change frequently, REST with proper cache headers can serve millions of requests without hitting the origin server.</p>
<p>It's also solid when the operation is simple request-response. If you're building straightforward CRUD operations with no streaming requirements and no complex data relationships, REST is simpler to implement, document, and debug than any alternative.</p>
<p>And finally use REST when developer experience for the consumer matters. REST APIs are immediately accessible in a browser. They can be tested with <code>curl</code>. Every developer already understands them.</p>
<h3 id="heading-when-to-use-graphql">When to Use GraphQL</h3>
<p>Use GraphQL when multiple client types have significantly different data needs. A mobile app that needs minimal data for a list view and richer data for a detail view, alongside a desktop app that needs comprehensive data, are ideal GraphQL consumers. Each queries exactly what it needs.</p>
<p>GraphQL also works well for complex interconnected data with many relationships. Social graphs, product catalogs with deeply nested attributes, or content management systems with rich content relationships: GraphQL's ability to traverse relationships in a single query is a genuine advantage.</p>
<p>It's also a good choice for frontend teams that need to iterate quickly. When the frontend can evolve its data requirements without backend changes, development velocity increases. New screens, new data combinations, no new endpoints needed.</p>
<p>And finally, GraphQL works well if you're comfortable with the operational complexity. GraphQL requires query complexity protection, custom caching strategies, and more sophisticated error handling. These are worth the effort when the data fetching advantages are real.</p>
<h3 id="heading-when-to-use-websockets">When to Use WebSockets</h3>
<p>Use WebSockets when both the client and server need to send messages at any time. Genuine bidirectional real-time communication where either party can initiate a message at any moment.</p>
<p>WebSockets also work great for chat, collaboration, and games. Live chat applications, collaborative document editing, multiplayer real-time games are the canonical WebSocket use cases.</p>
<p>And WebSockets is a solid choice when low-latency messaging is critical. The minimal framing overhead and persistent connection make WebSockets the lowest-latency option for frequent message exchange.</p>
<h3 id="heading-when-to-use-server-sent-events">When to Use Server-Sent Events</h3>
<p>Use SSE when the server needs to push updates but the client only reads. Notification feeds, live dashboards, streaming AI responses, real-time analytics, or any scenario where the server has a continuous stream of data to deliver and the client only consumes.</p>
<p>SSE also works well when you value simplicity over full bidirectionality. SSE is significantly simpler to implement and operate than WebSockets for one-directional use cases. Automatic reconnection is built in. It works over plain HTTP.</p>
<h3 id="heading-when-to-use-grpc">When to Use gRPC</h3>
<p>Use gRPC when multiple internal services share the same contract. When several teams build services that call each other, a <code>.proto</code> schema enforced by the compiler prevents contract drift. Everyone generates their clients from the same source of truth.</p>
<p>gRPC also works well for high-frequency service-to-service communication. Two microservices exchanging thousands of calls per second benefit from protobuf's compact binary encoding and HTTP/2's persistent multiplexed connections.</p>
<p>It's also a solid choice for large payloads that are consumed by many internal systems. An internal enterprise API with hundreds of fields called by dozens of internal applications benefits enormously from protobuf's size reduction. Less bandwidth, less parsing overhead, and compiled contract enforcement.</p>
<p>gRPC also works great when low-bandwidth networks matter. For mobile applications in markets where network conditions are variable or constrained, protobuf's binary encoding reduces payload size by 3 to 10 times compared to JSON. The difference between a 15 kilobyte response and a 3 kilobyte response is the difference between a 3-second load and a sub-second load on a 2G connection.</p>
<p>And finally, use gRPC when streaming is a core requirement and you want one framework. gRPC's four communication patterns (unary, server streaming, client streaming, and bidirectional) cover every scenario without requiring separate WebSocket infrastructure alongside your API.</p>
<h3 id="heading-the-hybrid-reality">The Hybrid Reality</h3>
<p>Most sophisticated systems use multiple approaches, each where it genuinely wins:</p>
<pre><code class="language-plaintext">A Large Engineering Organization

Public REST API
  External developers, partners, open integrations
  JSON over HTTPS. OpenAPI documentation.
  CDN caching for frequently accessed resources.

Internal gRPC Network
  Service-to-service communication
  Auth service, payment service, notification service,
  fraud detection: all communicating with typed contracts
  over efficient binary protobuf on HTTP/2.

Real-Time Layer
  WebSockets for bidirectional features (live chat, collaboration)
  SSE for one-directional feeds (notifications, live dashboards)
  gRPC streaming for real-time data with typed contracts

Mobile API
  REST for standard operations (profile, settings, history)
  gRPC for high-frequency or large payload calls
  SSE for notification streaming
</code></pre>
<p>There's no architectural purity requirement. Each layer uses what fits its requirements. The discipline is in making these choices deliberately rather than by habit or default.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The history of how clients and servers communicate is the history of engineers discovering the limitations of existing tools and building better ones.</p>
<p>HTTP/1.1 gave us a universal request-response protocol that built the web. Its text-based format and sequential connection model worked well for the web of the 1990s and 2000s. As applications became more complex and performance expectations rose, its limitations became bottlenecks.</p>
<p>HTTP/2 rebuilt the transport layer with binary framing and multiplexing, eliminating head-of-line blocking at the HTTP level, compressing headers, and enabling server push. HTTP/3 took this further by replacing TCP with QUIC, addressing the remaining head-of-line blocking at the transport level and making connection establishment faster.</p>
<p>JSON became the dominant data format because of its human readability and universal support. Protocol Buffers emerged as an alternative for contexts where JSON's verbosity and lack of schema enforcement create real problems: internal services, high-frequency communication, constrained networks, and teams needing compile-time contract enforcement.</p>
<p>REST codified HTTP's architectural strengths into a style that made APIs universally accessible and HTTP-native. Its success wasn't purely technical: it aligned with what developers already understood and what the HTTP ecosystem already supported. Its limitations in data fetching efficiency and real-time communication opened the door for GraphQL and streaming alternatives.</p>
<p>GraphQL solved REST's overfetching and underfetching problems by inverting control: the client specifies exactly what it needs. WebSockets solved REST's inability to support genuine bidirectional real-time communication. Server-Sent Events provided a simpler real-time option for one-directional streaming. gRPC combined Protocol Buffers, HTTP/2, and RPC semantics into a framework that excels at typed service-to-service communication at scale.</p>
<p>Understanding all of these tools, along with why each was built, what problem it solves, and where it struggles, is what enables you to make deliberate architectural decisions rather than defaulting to whatever is most familiar.</p>
<p>The right communication approach is always the one that fits the specific requirements of the system you're building: the clients consuming it, the data being exchanged, the network conditions it operates in, the teams building and maintaining it, and the operational complexity you are prepared to manage.</p>
<p>That clarity of fit is what engineering judgment looks like in practice.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ From RPC to gRPC: Understanding Remote Procedure Calls, Protocol Buffers, and Modern Distributed Systems Communication  ]]>
                </title>
                <description>
                    <![CDATA[ Every application, at some point, needs to talk to another system. A mobile app talks to a backend. A backend service talks to a payment gateway. An authentication service talks to a user service. A d ]]>
                </description>
                <link>https://www.freecodecamp.org/news/remote-procedure-calls-protocol-buffers-and-modern-distributed-systems-communication/</link>
                <guid isPermaLink="false">6a6145d945466c5d8ca2a549</guid>
                
                    <category>
                        <![CDATA[ gRPC ]]>
                    </category>
                
                    <category>
                        <![CDATA[ RPC ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Google ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Wed, 22 Jul 2026 22:36:09 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/1205581e-5729-44fa-837e-0f30981ea059.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every application, at some point, needs to talk to another system. A mobile app talks to a backend. A backend service talks to a payment gateway. An authentication service talks to a user service. A data pipeline talks to a storage layer.</p>
<p>The question is never whether systems need to communicate. The question is always how.</p>
<p>For years, REST over HTTP with JSON was the default answer. It works, it's simple, and the tooling is everywhere. But as systems grow in scale (in the number of services talking to each other, the volume of data being exchanged, and the need for real-time communication), REST starts to show its limits.</p>
<p>This is where Remote Procedure Calls, Protocol Buffers, and gRPC enter the picture.</p>
<p>In this handbook, you'll learn what RPC is and the problem it was designed to solve. You'll also understand Protocol Buffers: what they are, why they exist, and how they work.</p>
<p>You'll then see how Google combined these ideas into gRPC, one of the most powerful communication frameworks in modern distributed systems. You'll learn all four gRPC communication patterns, see code generated across multiple languages from a single contract file, and walk through a complete end-to-end Flutter implementation with production-grade concerns including authentication, error handling, and timeouts.</p>
<p>By the end, you won't just know what gRPC is. You'll understand when to use it, when not to, and how to think about service communication as a systems engineer.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-what-is-a-remote-procedure-call">What is a Remote Procedure Call</a>?</p>
</li>
<li><p><a href="#heading-the-problem-rpc-solves">The Problem RPC Solves</a></p>
</li>
<li><p><a href="#heading-why-grpc-over-rest-the-real-case">Why gRPC Over REST: The Real Case</a></p>
</li>
<li><p><a href="#heading-protocol-buffers-a-new-language-for-data">Protocol Buffers: A New Language for Data</a></p>
</li>
<li><p><a href="#heading-the-proto-file">The Proto File</a></p>
</li>
<li><p><a href="#heading-json-vs-protocol-buffers">JSON vs Protocol Buffers</a></p>
</li>
<li><p><a href="#heading-the-protoc-compiler-and-code-generation">The Protoc Compiler and Code Generation</a></p>
</li>
<li><p><a href="#heading-what-is-grpc">What is gRPC</a>?</p>
</li>
<li><p><a href="#heading-why-http2-matters-for-grpc">Why HTTP/2 Matters for gRPC</a></p>
</li>
<li><p><a href="#heading-the-four-grpc-communication-patterns">The Four gRPC Communication Patterns</a></p>
</li>
<li><p><a href="#heading-the-protobuf-repository-organizational-best-practice">The Protobuf Repository: Organizational Best Practice</a></p>
</li>
<li><p><a href="#heading-building-a-complete-grpc-system-with-dart-and-flutter">Building a Complete gRPC System with Dart and Flutter</a></p>
</li>
<li><p><a href="#heading-production-concerns">Production Concerns</a></p>
</li>
<li><p><a href="#heading-grpc-vs-rest-vs-websockets-when-to-use-what">gRPC vs REST vs WebSockets: When to Use What</a></p>
</li>
<li><p><a href="#heading-the-hybrid-architecture">The Hybrid Architecture</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-what-is-a-remote-procedure-call">What is a Remote Procedure Call?</h2>
<p>To understand RPCs, you first need to understand what a procedure call is.</p>
<p>A procedure call means invoking a procedure or function so that its code executes. For example, in Dart:</p>
<pre><code class="language-dart">double calculateTax(double amount) {
  return amount * 0.075;
}

final tax = calculateTax(50000); // local procedure call
</code></pre>
<p>You call <code>calculateTax</code>, pass an argument, and get a result back. The function lives on the same machine, in the same process, in the same memory space. This is a local procedure call.</p>
<p>A Remote Procedure Call takes this same idea and stretches it across a network. The function you're calling lives on a different machine, in a different process, and potentially in a different country. But from the caller's perspective, it feels exactly like calling a local function.</p>
<pre><code class="language-dart">// This looks like a local function call
final tax = await taxService.calculateTax(amount: 50000);

// But under the hood, this:
// 1. Serializes the argument into a binary format
// 2. Sends it over a network connection to a remote server
// 3. The server executes calculateTax with your argument
// 4. Serializes the result
// 5. Sends it back over the network
// 6. Deserializes it into a Dart object
// 7. Returns it to you as if it were local
</code></pre>
<p>The network complexity is completely hidden. You call a function. You get a result. Everything in between is handled by the RPC framework.</p>
<p>This is the fundamental idea behind RPC: make calling a remote function feel as natural as calling a local one.</p>
<h2 id="heading-the-problem-rpc-solves">The Problem RPC Solves</h2>
<p>To appreciate why RPC matters, you need to understand what the alternative looks like.</p>
<p>Without RPC, calling a remote service looks like this:</p>
<pre><code class="language-dart">Future&lt;double&gt; calculateTax(double amount) async {
  final response = await http.post(
    Uri.parse('https://tax-service.internal/api/v1/calculate'),
    headers: {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer $token',
    },
    body: jsonEncode({'amount': amount}),
  );

  if (response.statusCode != 200) {
    throw Exception('Tax calculation failed: ${response.statusCode}');
  }

  final data = jsonDecode(response.body);
  return (data['tax'] as num).toDouble();
}
</code></pre>
<p>Every service call requires you to:</p>
<ul>
<li><p>Know and hardcode the endpoint URL</p>
</li>
<li><p>Know the correct HTTP method</p>
</li>
<li><p>Manually serialize your request to JSON</p>
</li>
<li><p>Handle HTTP status codes yourself</p>
</li>
<li><p>Manually deserialize the response from JSON</p>
</li>
<li><p>Cast dynamic types to the types you actually expect</p>
</li>
<li><p>Hope the field names in the response match what you think they are</p>
</li>
</ul>
<p>Now multiply this by every single service call in your application. An authentication service, a user service, a payment service, a notification service, a transaction service. Every one of them requires the same manual boilerplate. Every one of them introduces the possibility of a typo in a field name, a wrong status code assumption, or a JSON deserialization failure that only surfaces at runtime.</p>
<p>With RPC:</p>
<pre><code class="language-dart">// Feels like a local function call
final tax = await taxService.calculateTax(
  TaxRequest(amount: 50000),
);
// tax is already a strongly typed TaxResponse object
// No URLs. No HTTP methods. No JSON parsing. No casting.
</code></pre>
<p>The framework handles everything. The function signature is defined in a contract file that both the client and server use. The types are enforced at compile time. If the server changes the response shape, the client fails to compile before anything reaches production.</p>
<p>This is what RPC solves: it removes the accidental complexity of network communication and lets you focus on what you're actually trying to do.</p>
<h2 id="heading-why-grpc-over-rest-the-real-case">Why gRPC Over REST: The Real Case</h2>
<p>Before going into the technical details of Protocol Buffers and gRPC, it's important to make a solid case for why you'd choose gRPC over REST in specific scenarios. This isn't a claim that gRPC is always better. It's a clear look at where it genuinely wins.</p>
<h3 id="heading-large-payloads-called-by-many-internal-systems">Large Payloads Called by Many Internal Systems</h3>
<p>Consider an internal enterprise API in a large telecommunications company. A single request and response payload for a plan registration or activation flow can contain over a thousand fields. This endpoint is called by dozens of internal applications: billing systems, CRM platforms, customer-facing mobile apps, internal dashboards, and partner portals.</p>
<p>With REST and JSON, every one of those applications sends and receives that thousand-field payload as text. Field names like <code>subscription_activation_status</code>, <code>rate_plan_identifier</code>, and <code>network_provisioning_reference</code> travel over the wire as strings on every single request. A significant portion of every payload isn't data. It's labels for data.</p>
<p>With Protocol Buffers, field names never appear in the payload at all. Only field numbers and values travel over the wire. That thousand-field payload shrinks dramatically. For an endpoint called millions of times per day by dozens of systems, the bandwidth saving is enormous and translates directly to infrastructure cost reduction.</p>
<p>Beyond size, the generated client guarantee is equally important. With REST, each of those dozens of applications reads the API documentation and builds its own understanding of the contract. When the backend changes a field name or type, not every application finds out immediately. Some find out in production when they break.</p>
<p>With a shared <code>.proto</code> file, every application generates its own strongly typed client from the same source. A contract change means every application regenerates. The compiler immediately reports where the breaking change affects each codebase. Nothing reaches production in a broken state.</p>
<h3 id="heading-low-bandwidth-and-remote-network-conditions">Low Bandwidth and Remote Network Conditions</h3>
<p>This is one of the most under-appreciated advantages of gRPC in markets where network quality varies significantly.</p>
<p>In many regions, a substantial portion of mobile users are on 2G or 3G connections. On a 2G connection, bandwidth can be as low as 50 to 100 kilobits per second. A REST JSON response that is 80 kilobytes takes over 6 seconds to download on a 2G connection. The equivalent protobuf binary, which can be 3 to 10 times smaller, takes under 2 seconds.</p>
<p>That difference isn't a technical footnote. It's the line between an application that feels usable and one that feels broken to a significant portion of your users. For any product that operates in markets with variable network conditions, protobuf's binary efficiency is a direct competitive advantage.</p>
<p>Beyond size, gRPC runs over HTTP/2 which maintains a single persistent connection rather than opening a new connection for every request. On slow networks where connection establishment (the TCP handshake and TLS negotiation) can itself take hundreds of milliseconds, reusing a single connection across many calls saves significant time over a session.</p>
<h3 id="heading-microservice-to-microservice-communication">Microservice to Microservice Communication</h3>
<p>When two internal services need to communicate, you have options. A message bus like Kafka or RabbitMQ is excellent when you don't need an immediate response, when the operation can happen asynchronously, and when you're broadcasting something that happened to multiple consumers.</p>
<p>But many service-to-service calls are synchronous by nature. An authentication service needs to validate a token right now before the request proceeds. A fraud detection service needs to assess a transaction right now before the payment is authorized. A pricing service needs to calculate a rate right now before the quote is generated. These operations can't publish an event and wait.</p>
<p>For synchronous service-to-service calls at high frequency, gRPC over HTTP/2 with protobuf encoding is significantly more efficient than REST. The persistent multiplexed connection means no connection setup overhead per call. The binary encoding means no JSON serialization and deserialization overhead on every hop. The generated clients mean both services compile against the same contract.</p>
<p>At scale, when two services are calling each other thousands of times per second, these efficiency differences compound into real performance and cost differences.</p>
<h3 id="heading-managing-api-contracts-across-multiple-teams">Managing API Contracts Across Multiple Teams</h3>
<p>In a large engineering organization, multiple teams build services that others depend on. REST API contracts live in documentation. Documentation goes stale. The backend team changes a field name. The mobile team finds out when users report crashes. The data team finds out when their pipeline throws an error at 2am.</p>
<p>gRPC's protobuf repository approach transforms contract management from a documentation problem into a code problem. Contract changes go through pull requests. Every dependent team reviews the change. Breaking changes are caught at compile time. Nobody is surprised in production.</p>
<p>This governance benefit scales with team size. The larger the organization, the more valuable it becomes.</p>
<h3 id="heading-real-time-communication">Real-Time Communication</h3>
<p>REST is request-response. The client asks and the server answers. The conversation ends. For real-time features, you either poll (wasteful) or bolt on a separate WebSocket server alongside your REST API (two different systems to maintain).</p>
<p>gRPC's streaming patterns handle real-time communication natively within the same framework you use for regular calls. A live balance update, a real-time transaction notification, or a bidirectional chat session all use the same generated client, the same connection, and the same protobuf encoding as your regular unary calls.</p>
<p>One framework with all communication patterns. No separate infrastructure.</p>
<h2 id="heading-protocol-buffers-a-new-language-for-data">Protocol Buffers: A New Language for Data</h2>
<p>RPC is a concept. To implement it, you need two things: a way to define the contract between client and server, and a way to serialize data efficiently for transmission over the network.</p>
<p>This is where Protocol Buffers comes in.</p>
<p>Protocol Buffers, commonly called protobuf, is a language-neutral, platform-neutral, extensible mechanism for serializing structured data. It was developed at Google in 2001, used internally for years, and open-sourced in 2008.</p>
<h3 id="heading-the-json-problem-at-scale">The JSON Problem at Scale</h3>
<p>JSON is the dominant data format for web APIs. It's human-readable, flexible, and universally supported. For many use cases, it's the right choice.</p>
<p>But JSON has structural inefficiencies that become painful at scale.</p>
<p>Consider a user profile response:</p>
<pre><code class="language-json">{
  "id": "usr_001",
  "first_name": "John",
  "last_name": "Smith",
  "email": "john@example.com",
  "phone_number": "+2348012345678",
  "account_type": "savings",
  "balance": 500000.00,
  "currency": "NGN",
  "is_verified": true,
  "is_active": true,
  "kyc_level": 3,
  "created_at": "2024-01-15T10:30:00Z",
  "last_login": "2026-07-20T09:15:00Z"
}
</code></pre>
<p>Every field name travels over the network as a string on every single response. <code>"first_name"</code>, <code>"account_type"</code>, <code>"phone_number"</code> aren't data. They're labels for data. But they consume bytes on every request.</p>
<p>Now consider an internal enterprise API with over a thousand fields in its request and response payload, being called by dozens of internal applications thousands of times per day. A significant portion of every payload is field name strings, not actual data. The overhead accumulates into real bandwidth and processing costs.</p>
<p>Beyond size, JSON has another problem: it has no schema at the network level. Nothing prevents a backend engineer from renaming <code>"first_name"</code> to <code>"firstName"</code> in a new deployment. The client breaks at runtime in production with real users.</p>
<h3 id="heading-what-protocol-buffers-do-differently">What Protocol Buffers Do Differently</h3>
<p>Protocol Buffers solve both problems with a fundamentally different approach to data encoding.</p>
<p>Instead of encoding data as human-readable text with field names, protobuf encodes data as compact binary using only field numbers and values. Field names never travel over the network.</p>
<p>Here's the same user profile defined in protobuf:</p>
<pre><code class="language-protobuf">message UserProfile {
  string id = 1;
  string first_name = 2;
  string last_name = 3;
  string email = 4;
  string phone_number = 5;
  string account_type = 6;
  double balance = 7;
  string currency = 8;
  bool is_verified = 9;
  bool is_active = 10;
  int32 kyc_level = 11;
  string created_at = 12;
  string last_login = 13;
}
</code></pre>
<p>When protobuf encodes this data, the output is binary that no human can read. But to a machine, it's extremely compact and fast to parse. The field numbers (1, 2, 3...) identify each field. The names never appear in the encoded output at all.</p>
<p>The result: the same user profile that's approximately 280 bytes in JSON is approximately 95 bytes in protobuf. That's three times smaller. For a thousand-field enterprise payload, this difference is enormous.</p>
<p>And because the schema is defined in a <code>.proto</code> file that both client and server compile against, field name changes are caught at compile time, not at runtime.</p>
<h2 id="heading-the-proto-file">The Proto File</h2>
<p>The <code>.proto</code> file is the heart of everything in the protobuf and gRPC ecosystem. It's where you define your data models and your service contracts.</p>
<p>It's written in Protocol Buffer Language (proto3) – not Go, not Dart, not Python, not Java. You write it in any text editor. VS Code with the <code>vscode-proto3</code> extension gives you syntax highlighting, autocomplete, and inline validation.</p>
<p>Here's a complete <code>.proto</code> file for a fintech platform:</p>
<pre><code class="language-csharp">syntax = "proto3";

package banking;

option go_package = "./banking";
option java_package = "com.fintech.banking";



service BankingService {
  // Unary: one request, one response
  rpc Login (LoginRequest) returns (LoginResponse);

  // Unary: fetch user profile
  rpc GetProfile (ProfileRequest) returns (UserProfile);

  // Server streaming: real-time balance updates
  rpc WatchBalance (BalanceRequest) returns (stream BalanceResponse);

  // Server streaming: live transaction feed
  rpc StreamTransactions (TransactionRequest) returns (stream Transaction);

  // Client streaming: upload KYC documents in chunks
  rpc UploadDocument (stream DocumentChunk) returns (UploadResponse);

  // Bidirectional streaming: live chat support
  rpc Chat (stream ChatMessage) returns (stream ChatMessage);
}



message LoginRequest {
  string email = 1;
  string password = 2;
}

message LoginResponse {
  string token = 1;
  string user_id = 2;
  int64 expires_at = 3;
}

message ProfileRequest {
  string user_id = 1;
}

message UserProfile {
  string id = 1;
  string first_name = 2;
  string last_name = 3;
  string email = 4;
  string phone_number = 5;
  string account_type = 6;
  double balance = 7;
  string currency = 8;
  bool is_verified = 9;
  int32 kyc_level = 10;
}

message BalanceRequest {
  string user_id = 1;
}

message BalanceResponse {
  double balance = 1;
  string currency = 2;
  int64 timestamp = 3;
}

message TransactionRequest {
  string user_id = 1;
  int32 limit = 2;
}

message Transaction {
  string id = 1;
  double amount = 2;
  string description = 3;
  string type = 4;
  int64 timestamp = 5;
}

message DocumentChunk {
  bytes data = 1;
  string document_type = 2;
  int32 chunk_index = 3;
  bool is_last = 4;
}

message UploadResponse {
  bool success = 1;
  string document_id = 2;
  string message = 3;
}

message ChatMessage {
  string sender_id = 1;
  string content = 2;
  int64 timestamp = 3;
}
</code></pre>
<p>Let's walk through every part of this file carefully.</p>
<h3 id="heading-the-syntax-declaration">The Syntax Declaration</h3>
<pre><code class="language-csharp">syntax = "proto3";
</code></pre>
<p>This tells the protobuf compiler which version of the Protocol Buffer language you're using. proto3 is the current standard. It must be the first non-comment line in every <code>.proto</code> file.</p>
<h3 id="heading-the-package-declaration">The Package Declaration</h3>
<pre><code class="language-csharp">package banking;
</code></pre>
<p>The package name prevents naming conflicts when you have multiple <code>.proto</code> files across different services. It functions like a namespace. If two services both define a <code>UserProfile</code> message, the package name distinguishes them: <code>banking.UserProfile</code> versus <code>auth.UserProfile</code>.</p>
<h3 id="heading-language-specific-options">Language-Specific Options</h3>
<pre><code class="language-protobuf">option go_package = "./banking";
option java_package = "com.fintech.banking";
</code></pre>
<p>These options tell the compiler how to organize the generated code for specific languages. They don't affect the proto file itself, only the generated output.</p>
<h3 id="heading-the-service-definition">The Service Definition</h3>
<pre><code class="language-csharp">service BankingService {
  rpc Login (LoginRequest) returns (LoginResponse);
  rpc WatchBalance (BalanceRequest) returns (stream BalanceResponse);
}
</code></pre>
<p>The <code>service</code> block defines the RPC contract. Think of it exactly like an abstract class in any object-oriented language. It declares what functions exist, what they accept, and what they return.</p>
<p>Each <code>rpc</code> line defines one remote procedure. The <code>stream</code> keyword before a type indicates that multiple messages will flow rather than just one.</p>
<h3 id="heading-message-definitions">Message Definitions</h3>
<pre><code class="language-csharp">message LoginRequest {
  string email = 1;
  string password = 2;
}
</code></pre>
<p>A <code>message</code> is a data structure. Think of it as a class with only fields: no methods, no logic. Each field has three parts.</p>
<p>The <strong>type</strong> can be <code>string</code>, <code>int32</code>, <code>int64</code>, <code>double</code>, <code>bool</code>, <code>bytes</code>, or another message type.</p>
<p>The <strong>name</strong> is the field name as it appears in generated code. This is for human readability only. It never appears in the binary encoding.</p>
<p>The <strong>field number</strong> (= 1, = 2, = 3) is the unique identifier that protobuf uses in the binary output instead of the field name. This is critical: once you assign a field number, you must never change it or reuse it. The binary encoding uses these numbers, not names. If you change a field number, old encoded data becomes unreadable.</p>
<p>You can safely add new fields with new numbers, remove fields (the number stays reserved, never reuse it), and rename fields (names don't appear in binary). You must never change a field number, reuse a removed field's number, or change a field's type.</p>
<h2 id="heading-json-vs-protocol-buffers">JSON vs Protocol Buffers</h2>
<p>Now that you understand both formats, let's make a direct comparison.</p>
<h3 id="heading-size-comparison">Size Comparison</h3>
<p>Let's look at the same login request in both formats:</p>
<p><strong>JSON (text):</strong></p>
<pre><code class="language-csharp">{
  "email": "john@example.com",
  "password": "securepassword123"
}
</code></pre>
<p>Approximately 55 bytes.</p>
<p><strong>Protobuf binary:</strong></p>
<p>Field 1 (email): tag + length + value bytes. Field 2 (password): tag + length + value bytes.</p>
<p>Approximately 38 bytes.</p>
<p>For a simple two-field message, the difference is modest. Now consider a thousand-field enterprise payload. Field names alone in JSON can account for 40-60% of the total payload size. In protobuf, field names contribute zero bytes to the payload.</p>
<p>On a 2G connection where bandwidth can be as low as 50 kilobits per second, the difference between an 80 kilobyte JSON response and a 15 kilobyte protobuf response is the difference between a 13-second load and a 2-second load. For users in areas with limited network infrastructure, this isn't a performance metric. It's a usability threshold.</p>
<h3 id="heading-speed-comparison">Speed Comparison</h3>
<p>Protobuf serialization and deserialization is significantly faster than JSON parsing because binary parsing requires no string tokenizing, quote handling, whitespace skipping, or type inference. The parser reads a field number, reads the value type, reads the value, and moves to the next field. It's a direct binary read.</p>
<p>JSON parsing must tokenize a string character by character, identify keys and values by their surrounding quotes and delimiters, infer types from the value format, and construct objects from dynamic maps.</p>
<p>On a mobile device handling hundreds of responses per session, this parsing difference translates to measurable CPU and battery savings.</p>
<h3 id="heading-schema-and-type-safety">Schema and Type Safety</h3>
<p>JSON has no schema enforcement at the network level. A backend can change <code>"balance"</code> to <code>"current_balance"</code> and the client only discovers this when the app crashes in production.</p>
<p>Protobuf schemas are enforced at compile time. If the <code>.proto</code> file changes in a way that breaks the client, the client fails to compile. The problem is caught before it reaches any user.</p>
<h3 id="heading-the-honest-comparison">The Honest Comparison</h3>
<table>
<thead>
<tr>
<th></th>
<th>JSON</th>
<th>Protocol Buffers</th>
</tr>
</thead>
<tbody><tr>
<td>Encoding</td>
<td>Text (UTF-8)</td>
<td>Binary</td>
</tr>
<tr>
<td>Human readable</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td>Payload size</td>
<td>Larger (field names included)</td>
<td>3 to 10 times smaller</td>
</tr>
<tr>
<td>Parse speed</td>
<td>Slower (text tokenizing)</td>
<td>Faster (direct binary read)</td>
</tr>
<tr>
<td>Schema enforcement</td>
<td>None at network level</td>
<td>Compile-time enforcement</td>
</tr>
<tr>
<td>Code generation</td>
<td>Optional</td>
<td>Required and automatic</td>
</tr>
<tr>
<td>Best for</td>
<td>Public APIs, human inspection</td>
<td>Internal services, high performance</td>
</tr>
</tbody></table>
<h2 id="heading-the-protoc-compiler-and-code-generation">The Protoc Compiler and Code Generation</h2>
<p>The <code>protoc</code> compiler reads your <code>.proto</code> file and generates code in any language you specify. This is where the universal contract becomes reality.</p>
<p><strong>Generating Go code (for the backend server):</strong></p>
<pre><code class="language-csharp">protoc \
  --go_out=. \
  --go-grpc_out=. \
  proto/banking.proto
</code></pre>
<p>Generates:</p>
<pre><code class="language-plaintext">banking.pb.go        &lt;- the message structs
banking_grpc.pb.go   &lt;- the server interface
</code></pre>
<p><strong>Generating Dart code (for the Flutter client):</strong></p>
<pre><code class="language-csharp">protoc \
  --dart_out=grpc:lib/generated \
  proto/banking.proto
</code></pre>
<p>Generates:</p>
<pre><code class="language-plaintext">lib/generated/
  banking.pb.dart        &lt;- the message classes
  banking.pbgrpc.dart    &lt;- the client stub
</code></pre>
<p><strong>Generating Python code (for a data service):</strong></p>
<pre><code class="language-csharp">protoc \
  --python_out=. \
  --grpc_python_out=. \
  proto/banking.proto
</code></pre>
<p>Generates:</p>
<pre><code class="language-plaintext">banking_pb2.py         &lt;- the message classes
banking_pb2_grpc.py    &lt;- the client and server classes
</code></pre>
<p><strong>Generating TypeScript code (for a web frontend):</strong></p>
<pre><code class="language-csharp">protoc \
  --ts_out=. \
  proto/banking.proto
</code></pre>
<p>Generates:</p>
<pre><code class="language-plaintext">banking.ts             &lt;- typed message classes and client
</code></pre>
<p>All of this from the same single <code>banking.proto</code> file.</p>
<p>The Go backend engineer never writes serialization code. The Flutter engineer never writes deserialization code. The Python data engineer never parses binary manually. The TypeScript web engineer never constructs HTTP requests. All of that is generated automatically from the contract that every team agreed on.</p>
<p>Here's what the generated code looks like in each language to make this concrete:</p>
<p><strong>Generated Go server interface (the backend implements this):</strong></p>
<pre><code class="language-go">// Generated — do not edit
type BankingServiceServer interface {
    Login(context.Context, *LoginRequest) (*LoginResponse, error)
    WatchBalance(*BalanceRequest, BankingService_WatchBalanceServer) error
    mustEmbedUnimplementedBankingServiceServer()
}

// The Go backend engineer writes this implementation
type bankingServer struct {
    pb.UnimplementedBankingServiceServer
}

func (s *bankingServer) Login(
    ctx context.Context,
    req *pb.LoginRequest,
) (*pb.LoginResponse, error) {
    token, err := authService.Login(req.Email, req.Password)
    if err != nil {
        return nil, status.Errorf(codes.Unauthenticated, "invalid credentials")
    }
    return &amp;pb.LoginResponse{
        Token:  token,
        UserId: user.Id,
    }, nil
}
</code></pre>
<p><strong>Generated Python client (the data team uses this):</strong></p>
<pre><code class="language-python">import grpc
import banking_pb2
import banking_pb2_grpc

channel = grpc.secure_channel(
    'api.fintech-platform.com:50051',
    grpc.ssl_channel_credentials()
)
stub = banking_pb2_grpc.BankingServiceStub(channel)

response = stub.Login(banking_pb2.LoginRequest(
    email='john@example.com',
    password='password123'
))

print(f"Token: {response.token}")
print(f"User ID: {response.user_id}")
</code></pre>
<p><strong>Generated Dart client (you use this in Flutter):</strong></p>
<pre><code class="language-dart">import 'package:grpc/grpc.dart';
import 'generated/banking.pbgrpc.dart';
import 'generated/banking.pb.dart';

final channel = ClientChannel('api.fintech-platform.com', port: 50051);
final client = BankingServiceClient(channel);

final response = await client.login(
  LoginRequest(email: 'john@example.com', password: 'password123'),
);

print('Token: ${response.token}');
print('User ID: ${response.userId}');
</code></pre>
<p>Three different languages. Three different teams. One <code>.proto</code> file. All of them are generated, strongly typed, and guaranteed to be in sync with the server.</p>
<h2 id="heading-what-is-grpc">What is gRPC?</h2>
<p>gRPC is Google's open-source Remote Procedure Call framework. It was open-sourced in 2016 and is now a Cloud Native Computing Foundation (CNCF) graduated project. This means it's been production-proven at the highest level of the cloud-native ecosystem.</p>
<p>gRPC combines three things:</p>
<ol>
<li><p><strong>Remote Procedure Calls</strong> as the programming model: calling remote functions like local ones.</p>
</li>
<li><p><strong>Protocol Buffers</strong> as the interface definition language and data serialization format: strongly typed contracts and compact binary encoding.</p>
</li>
<li><p><strong>HTTP/2</strong> as the transport protocol: multiplexed, persistent connections with binary framing.</p>
</li>
</ol>
<p>The combination of these three produces a framework that's faster than REST, more structured than WebSockets, and more powerful than any of its predecessors.</p>
<p>gRPC is used internally at Google for virtually all service-to-service communication. Netflix, Uber, Square, Dropbox, Lyft, and hundreds of other organizations use it for their internal microservice communication. Official support exists for Go, Java, Python, C++, C#, Ruby, Node.js, PHP, Dart, Kotlin, and more.</p>
<h2 id="heading-why-http2-matters-for-grpc">Why HTTP/2 Matters for gRPC</h2>
<p>gRPC is built exclusively on HTTP/2. Understanding what HTTP/2 provides is essential to understanding why gRPC performs the way it does.</p>
<p>HTTP/1.1, which powers most REST APIs, has fundamental performance constraints. Each request must complete before the next one begins on the same connection. Headers are sent as verbose text on every request. The server can't send data unless the client asks first.</p>
<p>HTTP/2 was designed to fix these constraints at the protocol level.</p>
<h3 id="heading-multiplexing">Multiplexing</h3>
<p>HTTP/2 introduces streams within a single connection. Multiple independent requests can travel over the same TCP connection simultaneously.</p>
<pre><code class="language-csharp">Single TCP connection to api.fintech-platform.com

Stream 1: Login request ---------&gt; Login response
Stream 2: Profile request -------&gt; Profile response
Stream 3: Balance request -------&gt; Balance stream (ongoing)
Stream 4: Transactions request --&gt; Transaction stream (ongoing)

All four streams active simultaneously over ONE connection
</code></pre>
<p>In HTTP/1.1, you would need four separate connections or wait for each to complete before starting the next. HTTP/2 handles all four over a single persistent connection with no waiting.</p>
<p>This is the foundation of gRPC's streaming capabilities. A persistent multiplexed connection is what allows the server to keep pushing balance updates and transaction notifications while the client continues making other calls.</p>
<h3 id="heading-binary-framing">Binary Framing</h3>
<p>HTTP/1.1 sends everything as text. HTTP/2 sends everything as binary frames. Binary is more compact and significantly faster for machines to parse.</p>
<p>Every gRPC message is broken into binary frames and sent over the HTTP/2 connection. Combined with protobuf's binary encoding, gRPC data travels in the most compact form possible at every layer.</p>
<h3 id="heading-header-compression-hpack">Header Compression (HPACK)</h3>
<p>HTTP/1.1 sends full headers on every request. An Authorization header carrying a JWT token can be 500 bytes or more, repeated on every request.</p>
<p>HTTP/2 uses HPACK compression. Headers sent on previous requests are cached. Subsequent requests only send headers that changed. The Authorization header, once sent, is referenced by a short index rather than retransmitted in full.</p>
<p>On a mobile application making dozens of authenticated requests per session, this compression is a meaningful bandwidth saving, especially on slow networks where every byte matters.</p>
<h3 id="heading-server-push">Server Push</h3>
<p>HTTP/2 allows the server to proactively send data to the client without waiting for a request. The client opens a stream and the server keeps pushing messages through it as events occur.</p>
<p>This is the mechanism behind gRPC server streaming. The client sends one <code>WatchBalance</code> request and the server pushes a new <code>BalanceResponse</code> every time the balance changes. No polling or repeated requests. The connection stays open and the server speaks whenever it has something new to say.</p>
<h2 id="heading-the-four-grpc-communication-patterns">The Four gRPC Communication Patterns</h2>
<p>This is the most important section of this article. gRPC doesn't have one communication model. It has four. Each one is defined precisely in the <code>.proto</code> file and serves different use cases.</p>
<h3 id="heading-pattern-1-unary-rpc">Pattern 1: Unary RPC</h3>
<p>One request from the client and one response from the server. This is identical to a REST API call in terms of the request-response flow.</p>
<pre><code class="language-csharp">rpc Login (LoginRequest) returns (LoginResponse);
</code></pre>
<pre><code class="language-plaintext">Client ----LoginRequest----&gt; Server
Client &lt;---LoginResponse---- Server
Done.
</code></pre>
<p><strong>When to use Unary RPC:</strong> Login, profile fetch, payment initiation, data creation, configuration retrieval: any operation that follows a simple ask-and-answer pattern.</p>
<p><strong>Dart implementation:</strong></p>
<pre><code class="language-dart">Future&lt;LoginResponse&gt; login(String email, String password) async {
  try {
    return await _client.login(
      LoginRequest(email: email, password: password),
    );
  } on GrpcError catch (e) {
    throw _mapGrpcError(e);
  }
}
</code></pre>
<p><strong>Go server implementation:</strong></p>
<pre><code class="language-go">func (s *bankingServer) Login(
    ctx context.Context,
    req *pb.LoginRequest,
) (*pb.LoginResponse, error) {
    user, err := s.authService.Login(req.Email, req.Password)
    if err != nil {
        return nil, status.Errorf(codes.Unauthenticated, "invalid credentials: %v", err)
    }
    token, _ := s.tokenService.Generate(user.Id)
    return &amp;pb.LoginResponse{
        Token:  token,
        UserId: user.Id,
    }, nil
}
</code></pre>
<h3 id="heading-pattern-2-server-streaming-rpc">Pattern 2: Server Streaming RPC</h3>
<p>One request from the client and a continuous stream of responses from the server. The connection stays open and the server pushes messages as they become available.</p>
<pre><code class="language-csharp">rpc WatchBalance (BalanceRequest) returns (stream BalanceResponse);
rpc StreamTransactions (TransactionRequest) returns (stream Transaction);
</code></pre>
<pre><code class="language-plaintext">Client ----BalanceRequest----&gt; Server
Client &lt;---BalanceResponse---- Server (balance: 500000)
Client &lt;---BalanceResponse---- Server (balance: 495000, after a debit)
Client &lt;---BalanceResponse---- Server (balance: 995000, after a credit)
[stream stays open, server pushes on every change]
</code></pre>
<p><strong>When to use Server Streaming:</strong> Live account balance, real-time transaction notifications, live stock prices, sports scores, news feeds, system monitoring dashboards: anything where the server has an ongoing series of updates to deliver.</p>
<p><strong>Dart implementation:</strong></p>
<pre><code class="language-csharp">Stream&lt;BalanceResponse&gt; watchBalance(String userId) {
  return _client.watchBalance(
    BalanceRequest(userId: userId),
  );
}
</code></pre>
<p>In Flutter, consume this with a <code>StreamBuilder</code>:</p>
<pre><code class="language-csharp">StreamBuilder&lt;BalanceResponse&gt;(
  stream: _dataSource.watchBalance(currentUserId),
  builder: (context, snapshot) {
    if (snapshot.connectionState == ConnectionState.waiting) {
      return const CircularProgressIndicator();
    }

    if (snapshot.hasError) {
      return Text('Error: ${snapshot.error}');
    }

    if (!snapshot.hasData) {
      return const Text('Waiting for balance...');
    }

    final balance = snapshot.data!;
    return Column(
      children: [
        Text(
          '${balance.currency} ${balance.balance.toStringAsFixed(2)}',
          style: const TextStyle(
            fontSize: 36,
            fontWeight: FontWeight.bold,
          ),
        ),
        Text(
          'Updated: ${DateTime.fromMillisecondsSinceEpoch(balance.timestamp.toInt())}',
        ),
      ],
    );
  },
)
</code></pre>
<p>Every time the server pushes a new balance, the <code>StreamBuilder</code> calls <code>builder</code> again and the widget shows the updated value. There's zero polling logic or manual refresh. The server speaks and the widget listens.</p>
<p><strong>Go server implementation:</strong></p>
<pre><code class="language-csharp">func (s *bankingServer) WatchBalance(
    req *pb.BalanceRequest,
    stream pb.BankingService_WatchBalanceServer,
) error {
    ticker := time.NewTicker(time.Second)
    defer ticker.Stop()

    for {
        select {
        case &lt;-stream.Context().Done():
            return nil
        case &lt;-ticker.C:
            balance, err := s.accountService.GetBalance(req.UserId)
            if err != nil {
                return status.Errorf(codes.Internal, "failed to fetch balance: %v", err)
            }

            if err := stream.Send(&amp;pb.BalanceResponse{
                Balance:   balance.Amount,
                Currency:  balance.Currency,
                Timestamp: time.Now().UnixMilli(),
            }); err != nil {
                return err
            }
        }
    }
}
</code></pre>
<h3 id="heading-pattern-3-client-streaming-rpc">Pattern 3: Client Streaming RPC</h3>
<p>The client sends a stream of messages to the server. The server processes them all and responds once at the end.</p>
<pre><code class="language-csharp">rpc UploadDocument (stream DocumentChunk) returns (UploadResponse);
</code></pre>
<pre><code class="language-plaintext">Client ----Chunk 1 (bytes 0-1024)-----&gt; Server
Client ----Chunk 2 (bytes 1024-2048)--&gt; Server
Client ----Chunk 3 (bytes 2048-3072)--&gt; Server
Client ----Chunk 4 (last chunk)-------&gt; Server
Client &lt;---UploadResponse-------------- Server (document_id: "doc_001")
</code></pre>
<p><strong>When to use Client Streaming:</strong> Uploading large files (KYC documents, profile photos) in chunks, sending a batch of sensor readings, submitting bulk records to a server.</p>
<p><strong>Dart implementation:</strong></p>
<pre><code class="language-dart">Future&lt;UploadResponse&gt; uploadDocument(
  List&lt;Uint8List&gt; chunks,
  String documentType,
) async {
  try {
    Stream&lt;DocumentChunk&gt; chunkStream() async* {
      for (int i = 0; i &lt; chunks.length; i++) {
        yield DocumentChunk(
          data: chunks[i],
          documentType: documentType,
          chunkIndex: i,
          isLast: i == chunks.length - 1,
        );
      }
    }

    return await _client.uploadDocument(chunkStream());
  } on GrpcError catch (e) {
    throw _mapGrpcError(e);
  }
}
</code></pre>
<p><code>chunkStream()</code> is an async generator function. The <code>async*</code> keyword means it yields values over time rather than returning a single value. Each <code>yield</code> produces one <code>DocumentChunk</code> message that gRPC sends to the server. The server receives these one by one and processes them all before sending a single <code>UploadResponse</code> at the end.</p>
<p><strong>Go server implementation:</strong></p>
<pre><code class="language-go">func (s *bankingServer) UploadDocument(
    stream pb.BankingService_UploadDocumentServer,
) error {
    var allData []byte
    var documentType string

    for {
        chunk, err := stream.Recv()
        if err == io.EOF {
            break
        }
        if err != nil {
            return status.Errorf(codes.Internal, "failed to receive chunk: %v", err)
        }

        allData = append(allData, chunk.Data...)
        documentType = chunk.DocumentType
    }

    docId, err := s.documentService.Store(allData, documentType)
    if err != nil {
        return status.Errorf(codes.Internal, "failed to store document: %v", err)
    }

    return stream.SendAndClose(&amp;pb.UploadResponse{
        Success:    true,
        DocumentId: docId,
        Message:    "Document uploaded successfully",
    })
}
</code></pre>
<h3 id="heading-pattern-4-bidirectional-streaming-rpc">Pattern 4: Bidirectional Streaming RPC</h3>
<p>Both the client and server stream messages simultaneously. Both sides can send at any time. Neither waits for the other.</p>
<pre><code class="language-csharp">rpc Chat (stream ChatMessage) returns (stream ChatMessage);
</code></pre>
<pre><code class="language-plaintext">Client ----"Hello"---------------------------&gt; Server
Server &lt;---"Hi, how can I help?"-------------- Client
Client ----"What is my account balance?"-----&gt; Server
Server &lt;---"Your balance is NGN 500,000"------ Client
Server &lt;---"New transaction alert: -5,000"---- Client (server-initiated)
Client ----"Thanks"--------------------------&gt; Server
[both sides communicate freely and simultaneously]
</code></pre>
<p><strong>When to use Bidirectional Streaming:</strong> Real-time chat, live collaborative document editing, multiplayer game state synchronization, interactive trading terminals, real-time customer support sessions.</p>
<p><strong>Dart implementation:</strong></p>
<pre><code class="language-csharp">void startChat(String userId) {
  final outgoing = StreamController&lt;ChatMessage&gt;();

  final incoming = _client.chat(outgoing.stream);

  incoming.listen(
    (message) {
      print('${message.senderId}: ${message.content}');
    },
    onError: (error) {
      print('Chat error: $error');
    },
    onDone: () {
      print('Chat session ended');
    },
  );

  outgoing.add(ChatMessage(
    senderId: userId,
    content: 'Hello, I need help with my account',
    timestamp: DateTime.now().millisecondsSinceEpoch,
  ));
}
</code></pre>
<p><code>StreamController</code> manages the outgoing message stream. You add messages to <code>outgoing</code> whenever the user sends something. The incoming stream delivers messages from the server. Both run simultaneously over the same HTTP/2 connection.</p>
<p><strong>Go server implementation:</strong></p>
<pre><code class="language-go">func (s *bankingServer) Chat(stream pb.BankingService_ChatServer) error {
    for {
        msg, err := stream.Recv()
        if err == io.EOF {
            return nil
        }
        if err != nil {
            return err
        }

        response := s.chatService.Process(msg)
        if err := stream.Send(&amp;pb.ChatMessage{
            SenderId:  "support_agent",
            Content:   response,
            Timestamp: time.Now().UnixMilli(),
        }); err != nil {
            return err
        }
    }
}
</code></pre>
<h2 id="heading-the-protobuf-repository-organizational-best-practice">The Protobuf Repository: Organizational Best Practice</h2>
<p>In a small project, the <code>.proto</code> file can live inside the backend repository. The mobile engineer clones the backend repo to get it. This works at small scale.</p>
<p>In any organization of meaningful size, this approach breaks down. The backend repo becomes the source of truth, giving the backend team unilateral control over the contract. Other teams find out about changes when their builds break.</p>
<p>The industry best practice is a dedicated protobuf repository: a standalone repository that belongs to everyone and is owned exclusively by no one.</p>
<pre><code class="language-plaintext">fintech-api-contracts/
  proto/
    auth/
      auth.proto
    banking/
      banking.proto
    payments/
      payments.proto
    notifications/
      notifications.proto
    kyc/
      kyc.proto
  scripts/
    generate_dart.sh
    generate_go.sh
    generate_python.sh
  README.md
</code></pre>
<h3 id="heading-how-contract-changes-work">How Contract Changes Work</h3>
<p>Every API change follows the same process:</p>
<pre><code class="language-plaintext">Engineer proposes a change to banking.proto
          |
          raises a Pull Request in fintech-api-contracts
          |
Flutter team lead reviews:
  "Does this break our client? Do we need to update?"

Go backend lead reviews:
  "Is this implementable? Does it follow our conventions?"

React web lead reviews:
  "Does the web client need changes?"

Python data lead reviews:
  "Does this affect our data pipelines?"
          |
All teams approve
          |
PR merges — the change is now the law
          |
Every team runs their code generation script
          |
Builds fail where breaking changes exist
Changes are caught at compile time
Before any code reaches production
</code></pre>
<p>This process gives you something REST with documentation can never provide: guaranteed contract synchronization across every team, enforced by the compiler, before anything reaches users.</p>
<h2 id="heading-building-a-complete-grpc-system-with-dart-and-flutter">Building a Complete gRPC System with Dart and Flutter</h2>
<p>Now let's put everything together in a complete, production-structured example.</p>
<h3 id="heading-project-setup">Project Setup</h3>
<p>Add the gRPC dependency to your Flutter project:</p>
<pre><code class="language-yaml"># pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  grpc: ^3.2.4
  protobuf: ^3.1.0

dev_dependencies:
  protoc_plugin: ^21.1.2
</code></pre>
<p>Install the protoc compiler and the Dart plugin:</p>
<pre><code class="language-yaml"># macOS
brew install protobuf

# Install the Dart protoc plugin
dart pub global activate protoc_plugin
</code></pre>
<p>Generate the Dart code from the proto file:</p>
<pre><code class="language-bash">protoc \
  --dart_out=grpc:lib/generated \
  -I proto \
  proto/banking/banking.proto
</code></pre>
<h3 id="heading-the-data-source-layer">The Data Source Layer</h3>
<pre><code class="language-dart">// lib/features/banking/data/datasources/banking_remote_datasource.dart

import 'package:grpc/grpc.dart';
import '../../../../generated/banking.pb.dart';
import '../../../../generated/banking.pbgrpc.dart';
import '../../../../core/error/app_exception.dart';

class BankingRemoteDataSource {
  late final BankingServiceClient _client;
  late final ClientChannel _channel;

  BankingRemoteDataSource({
    required String host,
    required int port,
    required String authToken,
  }) {
   
    _channel = ClientChannel(
      host,
      port: port,
      options: const ChannelOptions(
        credentials: ChannelCredentials.secure(),
        connectionTimeout: Duration(seconds: 10),
      ),
    );

   
    _client = BankingServiceClient(
      _channel,
      options: CallOptions(
        metadata: {'authorization': 'Bearer $authToken'},
        timeout: const Duration(seconds: 30),
      ),
    );
  }

  Future&lt;LoginResponse&gt; login(String email, String password) async {
    try {
      return await _client.login(
        LoginRequest(email: email, password: password),
      );
    } on GrpcError catch (e) {
      throw _mapGrpcError(e);
    }
  }

  Future&lt;UserProfile&gt; getProfile(String userId) async {
    try {
      return await _client.getProfile(
        ProfileRequest(userId: userId),
      );
    } on GrpcError catch (e) {
      throw _mapGrpcError(e);
    }
  }

  Stream&lt;BalanceResponse&gt; watchBalance(String userId) {
    return _client
        .watchBalance(BalanceRequest(userId: userId))
        .handleError((error) {
      if (error is GrpcError) throw _mapGrpcError(error);
      throw error;
    });
  }

  Stream&lt;Transaction&gt; streamTransactions(String userId, {int limit = 20}) {
    return _client
        .streamTransactions(
          TransactionRequest(userId: userId, limit: limit),
        )
        .handleError((error) {
      if (error is GrpcError) throw _mapGrpcError(error);
      throw error;
    });
  }

  Future&lt;UploadResponse&gt; uploadDocument(
    List&lt;Uint8List&gt; chunks,
    String documentType,
  ) async {
    try {
      Stream&lt;DocumentChunk&gt; chunkStream() async* {
        for (int i = 0; i &lt; chunks.length; i++) {
          yield DocumentChunk(
            data: chunks[i],
            documentType: documentType,
            chunkIndex: i,
            isLast: i == chunks.length - 1,
          );
        }
      }
      return await _client.uploadDocument(chunkStream());
    } on GrpcError catch (e) {
      throw _mapGrpcError(e);
    }
  }

  ResponseStream&lt;ChatMessage&gt; startChat(Stream&lt;ChatMessage&gt; outgoing) {
    return _client.chat(outgoing);
  }

  Future&lt;void&gt; dispose() async {
    await _channel.shutdown();
  }

  AppException _mapGrpcError(GrpcError error) {
    switch (error.code) {
      case StatusCode.unauthenticated:
        return AppException.unauthorized(
          message: error.message ?? 'Unauthorized',
        );
      case StatusCode.notFound:
        return AppException.notFound(
          message: error.message ?? 'Not found',
        );
      case StatusCode.deadlineExceeded:
        return AppException.timeout(message: 'Request timed out');
      case StatusCode.unavailable:
        return AppException.serverUnavailable(
          message: 'Service unavailable',
        );
      default:
        return AppException.unknown(
          message: error.message ?? 'Unknown error',
        );
    }
  }
}
</code></pre>
<p>Let's walk through the important decisions in this data source.</p>
<h4 id="heading-the-channel">The Channel:</h4>
<pre><code class="language-csharp">_channel = ClientChannel(
  host,
  port: port,
  options: const ChannelOptions(
    credentials: ChannelCredentials.secure(),
    connectionTimeout: Duration(seconds: 10),
  ),
);
</code></pre>
<p>The channel is the physical HTTP/2 connection to the server. You create it once and reuse it for every call. <code>ChannelCredentials.secure()</code> enables TLS encryption. <code>connectionTimeout</code> prevents the app from waiting indefinitely if the server is unreachable.</p>
<p>The channel is the core of gRPC's performance advantage. A single persistent channel multiplexes all requests through one HTTP/2 connection. Creating a new channel per request would eliminate this advantage entirely and perform worse than REST.</p>
<h4 id="heading-authentication-via-metadata">Authentication via Metadata:</h4>
<pre><code class="language-dart">_client = BankingServiceClient(
  _channel,
  options: CallOptions(
    metadata: {'authorization': 'Bearer $authToken'},
    timeout: const Duration(seconds: 30),
  ),
);
</code></pre>
<p>gRPC uses metadata (key-value pairs) for what HTTP uses headers. Passing the auth token as metadata on the <code>CallOptions</code> means every single call made through this client automatically includes the Authorization metadata. You write it once. It applies everywhere.</p>
<h4 id="heading-error-mapping">Error Mapping:</h4>
<pre><code class="language-csharp">AppException _mapGrpcError(GrpcError error) {
  switch (error.code) {
    case StatusCode.unauthenticated:
      return AppException.unauthorized(...);
    case StatusCode.deadlineExceeded:
      return AppException.timeout(...);
    ...
  }
}
</code></pre>
<p>gRPC has its own set of status codes similar to HTTP status codes but not identical. Mapping them to your application's exception types at the data source layer means the rest of your code (use cases, notifiers, widgets) never deals with gRPC-specific errors directly. Your domain layer stays clean and framework-independent.</p>
<h3 id="heading-the-repository-layer">The Repository Layer</h3>
<pre><code class="language-dart">
abstract class BankingRepository {
  Future&lt;Result&lt;UserProfile, AppException&gt;&gt; getProfile(String userId);
  Stream&lt;BalanceResponse&gt; watchBalance(String userId);
  Stream&lt;Transaction&gt; streamTransactions(String userId);
  Future&lt;Result&lt;UploadResponse, AppException&gt;&gt; uploadDocument(
    List&lt;Uint8List&gt; chunks,
    String documentType,
  );
}


class BankingRepositoryImpl implements BankingRepository {
  final BankingRemoteDataSource _dataSource;

  BankingRepositoryImpl(this._dataSource);

  @override
  Future&lt;Result&lt;UserProfile, AppException&gt;&gt; getProfile(String userId) async {
    try {
      final profile = await _dataSource.getProfile(userId);
      return Result.success(profile);
    } on AppException catch (e) {
      return Result.failure(e);
    }
  }

  @override
  Stream&lt;BalanceResponse&gt; watchBalance(String userId) {
    return _dataSource.watchBalance(userId);
  }

  @override
  Stream&lt;Transaction&gt; streamTransactions(String userId) {
    return _dataSource.streamTransactions(userId);
  }

  @override
  Future&lt;Result&lt;UploadResponse, AppException&gt;&gt; uploadDocument(
    List&lt;Uint8List&gt; chunks,
    String documentType,
  ) async {
    try {
      final response = await _dataSource.uploadDocument(chunks, documentType);
      return Result.success(response);
    } on AppException catch (e) {
      return Result.failure(e);
    }
  }
}
</code></pre>
<h3 id="heading-the-riverpod-providers">The Riverpod Providers</h3>
<pre><code class="language-dart">
part 'banking_providers.g.dart';

@riverpod
Stream&lt;BalanceResponse&gt; balanceStream(BalanceStreamRef ref, String userId) {
  final repository = ref.watch(bankingRepositoryProvider);
  return repository.watchBalance(userId);
}

@riverpod
Stream&lt;Transaction&gt; transactionStream(
  TransactionStreamRef ref,
  String userId,
) {
  final repository = ref.watch(bankingRepositoryProvider);
  return repository.streamTransactions(userId);
}
</code></pre>
<p>With Riverpod, a provider that returns a <code>Stream</code> automatically becomes an <code>AsyncValue</code> that widgets can watch. Every new value pushed from the gRPC server stream triggers a widget rebuild automatically.</p>
<h3 id="heading-the-ui">The UI</h3>
<pre><code class="language-dart">// lib/features/banking/presentation/pages/dashboard_page.dart
class DashboardPage extends ConsumerWidget {
  final String userId;

  const DashboardPage({required this.userId, super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final balanceAsync = ref.watch(balanceStreamProvider(userId));
    final transactionsAsync = ref.watch(transactionStreamProvider(userId));

    return Scaffold(
      appBar: AppBar(title: const Text('Dashboard')),
      body: Column(
        children: [
          balanceAsync.when(
            data: (balance) =&gt; BalanceCard(
              amount: balance.balance,
              currency: balance.currency,
            ),
            loading: () =&gt; const BalanceShimmer(),
            error: (e, _) =&gt; ErrorCard(message: e.toString()),
          ),

          const SizedBox(height: 24),

          Expanded(
            child: transactionsAsync.when(
              data: (transaction) =&gt; TransactionTile(
                transaction: transaction,
              ),
              loading: () =&gt; const TransactionShimmer(),
              error: (e, _) =&gt; ErrorCard(message: e.toString()),
            ),
          ),
        ],
      ),
    );
  }
}
</code></pre>
<p>Both the balance and transactions arrive through gRPC server streams. Both update in real-time as the server pushes new data. Both are handled identically through Riverpod's <code>AsyncValue</code> pattern. One framework with all patterns covered.</p>
<h2 id="heading-production-concerns">Production Concerns</h2>
<h3 id="heading-authentication-with-interceptors">Authentication with Interceptors</h3>
<p>For more granular authentication control, such as refreshing an expired token and retrying automatically, you implement a client interceptor:</p>
<pre><code class="language-dart">class AuthInterceptor extends ClientInterceptor {
  final TokenService _tokenService;

  AuthInterceptor(this._tokenService);

  @override
  ResponseFuture&lt;R&gt; interceptUnary&lt;Q, R&gt;(
    ClientMethod&lt;Q, R&gt; method,
    Q request,
    CallOptions options,
    ClientUnaryInvoker&lt;Q, R&gt; invoker,
  ) {
    final token = _tokenService.currentToken;
    final authenticatedOptions = options.mergedWith(
      CallOptions(metadata: {'authorization': 'Bearer $token'}),
    );
    return invoker(method, request, authenticatedOptions);
  }

  @override
  ResponseStream&lt;R&gt; interceptServerStreaming&lt;Q, R&gt;(
    ClientMethod&lt;Q, R&gt; method,
    Q request,
    CallOptions options,
    ClientServerStreamingInvoker&lt;Q, R&gt; invoker,
  ) {
    final token = _tokenService.currentToken;
    final authenticatedOptions = options.mergedWith(
      CallOptions(metadata: {'authorization': 'Bearer $token'}),
    );
    return invoker(method, request, authenticatedOptions);
  }
}
</code></pre>
<p>Pass the interceptor when creating the client:</p>
<pre><code class="language-dart">_client = BankingServiceClient(
  _channel,
  interceptors: [AuthInterceptor(tokenService)],
);
</code></pre>
<p>The interceptor fires on every call automatically. The data source code never touches auth logic directly.</p>
<h3 id="heading-error-handling-grpc-status-codes">Error Handling: gRPC Status Codes</h3>
<p>gRPC defines a standard set of status codes that every implementation follows:</p>
<table>
<thead>
<tr>
<th>Status Code</th>
<th>Meaning</th>
<th>Recommended Action</th>
</tr>
</thead>
<tbody><tr>
<td>OK (0)</td>
<td>Success</td>
<td>Use the response</td>
</tr>
<tr>
<td>CANCELLED (1)</td>
<td>Client cancelled the call</td>
<td>Ignore or log</td>
</tr>
<tr>
<td>UNKNOWN (2)</td>
<td>Unknown server error</td>
<td>Show generic error</td>
</tr>
<tr>
<td>INVALID_ARGUMENT (3)</td>
<td>Bad request data</td>
<td>Show validation error</td>
</tr>
<tr>
<td>DEADLINE_EXCEEDED (4)</td>
<td>Call timed out</td>
<td>Retry or show timeout message</td>
</tr>
<tr>
<td>NOT_FOUND (5)</td>
<td>Resource does not exist</td>
<td>Show not found UI</td>
</tr>
<tr>
<td>ALREADY_EXISTS (6)</td>
<td>Duplicate resource</td>
<td>Show conflict message</td>
</tr>
<tr>
<td>PERMISSION_DENIED (7)</td>
<td>Insufficient permissions</td>
<td>Show access denied</td>
</tr>
<tr>
<td>UNAUTHENTICATED (16)</td>
<td>Invalid or expired credentials</td>
<td>Navigate to login</td>
</tr>
<tr>
<td>RESOURCE_EXHAUSTED (8)</td>
<td>Rate limited</td>
<td>Back off and retry</td>
</tr>
<tr>
<td>UNAVAILABLE (14)</td>
<td>Server temporarily down</td>
<td>Show offline message</td>
</tr>
</tbody></table>
<pre><code class="language-dart">AppException _mapGrpcError(GrpcError error) {
  switch (error.code) {
    case StatusCode.unauthenticated:
      return AppException.unauthorized(message: 'Session expired');
    case StatusCode.permissionDenied:
      return AppException.forbidden(message: 'Access denied');
    case StatusCode.notFound:
      return AppException.notFound(message: error.message ?? 'Not found');
    case StatusCode.deadlineExceeded:
      return AppException.timeout(message: 'Request timed out');
    case StatusCode.unavailable:
      return AppException.serverUnavailable(message: 'Service unavailable');
    case StatusCode.resourceExhausted:
      return AppException.rateLimited(message: 'Too many requests');
    case StatusCode.invalidArgument:
      return AppException.validation(
        message: error.message ?? 'Invalid input',
      );
    default:
      return AppException.unknown(
        message: error.message ?? 'An error occurred',
      );
  }
}
</code></pre>
<h3 id="heading-deadlines-and-timeouts">Deadlines and Timeouts</h3>
<p>Every gRPC call should have a deadline. Without deadlines, a slow server can make your app hang indefinitely.</p>
<p>Per-call deadline:</p>
<pre><code class="language-dart">Future&lt;UserProfile&gt; getProfile(String userId) async {
  return await _client.getProfile(
    ProfileRequest(userId: userId),
    options: CallOptions(timeout: const Duration(seconds: 10)),
  );
}
</code></pre>
<p>Default deadline for all calls:</p>
<pre><code class="language-dart">_client = BankingServiceClient(
  _channel,
  options: CallOptions(
    timeout: const Duration(seconds: 30),
    metadata: {'authorization': 'Bearer $authToken'},
  ),
);
</code></pre>
<p>When the deadline is exceeded, the call throws a <code>GrpcError</code> with <code>StatusCode.deadlineExceeded</code>, which your error mapper handles appropriately.</p>
<h3 id="heading-logging-interceptor">Logging Interceptor</h3>
<pre><code class="language-dart">class LoggingInterceptor extends ClientInterceptor {
  @override
  ResponseFuture&lt;R&gt; interceptUnary&lt;Q, R&gt;(
    ClientMethod&lt;Q, R&gt; method,
    Q request,
    CallOptions options,
    ClientUnaryInvoker&lt;Q, R&gt; invoker,
  ) {
    final stopwatch = Stopwatch()..start();
    debugPrint('[gRPC] --&gt; ${method.path}');

    final response = invoker(method, request, options);

    response.then((_) {
      stopwatch.stop();
      debugPrint(
        '[gRPC] &lt;-- ${method.path} (${stopwatch.elapsedMilliseconds}ms)',
      );
    }).catchError((error) {
      stopwatch.stop();
      debugPrint(
        '[gRPC] ERROR ${method.path}: $error (${stopwatch.elapsedMilliseconds}ms)',
      );
    });

    return response;
  }
}
</code></pre>
<h2 id="heading-grpc-vs-rest-vs-websockets-when-to-use-what">gRPC vs REST vs WebSockets: When to Use What</h2>
<h3 id="heading-when-to-use-rest">When to Use REST</h3>
<p>The API is consumed by third-party developers or external partners. JSON over HTTP is the universal language that every developer in every language can access immediately without learning new tooling.</p>
<p>The operation is simple request-response with no streaming requirements and only one client platform. REST is simpler to implement, simpler to debug, and simpler to test for straightforward CRUD operations.</p>
<p>Public documentation and human readability matter. REST with OpenAPI/Swagger gives you browsable, testable documentation that developers can explore in a browser.</p>
<p>Caching is important. REST GET responses can be cached at every layer: CDN, reverse proxy, browser cache. gRPC requests can't leverage standard HTTP caching.</p>
<h3 id="heading-use-websockets-when">Use WebSockets When</h3>
<p>You need true bidirectional real-time communication and gRPC isn't already in your stack. Chat applications, multiplayer games, and collaborative tools where both sides need to speak freely are natural WebSocket use cases.</p>
<p>Browser support without a proxy layer is required. WebSockets work natively in every modern browser. gRPC in the browser requires gRPC-Web and a proxy layer.</p>
<h3 id="heading-when-to-use-grpc">When to Use gRPC</h3>
<p>Multiple platform teams share the same service contract. When Flutter, React, Go, and Python services all call the same backend, a <code>.proto</code> file enforced by the compiler prevents contract drift across every team.</p>
<p>Large payloads are called by many internal applications. The more fields in the payload and the more applications consuming it, the stronger the case for protobuf's binary encoding and generated clients.</p>
<p>Network conditions are variable and payload size matters. Users on 2G or 3G connections benefit directly from protobuf's compact binary format. The same data in protobuf can be 3 to 10 times smaller than JSON, translating to faster load times and lower data consumption for users on limited data plans.</p>
<p>Real-time streaming is required and you want one unified framework for all communication patterns. gRPC's four patterns cover every scenario without requiring a separate WebSocket server alongside your API.</p>
<p>Service-to-service communication at high frequency is involved. Two internal services calling each other thousands of times per second over a persistent multiplexed HTTP/2 connection with binary protobuf encoding will significantly outperform REST with JSON over HTTP/1.1.</p>
<h2 id="heading-the-hybrid-architecture">The Hybrid Architecture</h2>
<p>The mature engineering decision is not choosing gRPC over REST or REST over gRPC. It's knowing where each belongs and using both deliberately.</p>
<p>Most organizations of meaningful scale end up with a hybrid:</p>
<p>The public REST API serves external consumers who need simplicity and JSON. The internal gRPC network handles high-frequency, high-performance service-to-service calls. The mobile gRPC endpoints give Flutter clients real-time capabilities over efficient binary connections.</p>
<p>Each layer uses the right tool for its specific requirements. No ideological commitment to one protocol. Pure engineering pragmatism.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Remote Procedure Calls began with a simple observation: network communication shouldn't require developers to think about network communication. Calling a function on another machine should feel like calling a function on your own.</p>
<p>Protocol Buffers took this further by solving the data problem. JSON is readable but verbose. Binary encoding with a compiler-enforced schema produces payloads that are smaller, faster to parse, and guaranteed to match the contract every team agreed on. For users on slow networks and internal systems processing millions of requests daily, this efficiency is a business advantage.</p>
<p>gRPC combined RPC semantics, Protocol Buffer encoding, and HTTP/2 transport into a framework that supports four distinct communication patterns: unary request-response, server streaming, client streaming, and bidirectional streaming. All from the same generated client, using the same persistent connection, and enforced by the same <code>.proto</code> contract.</p>
<p>The organizational practice of a shared protobuf repository transforms gRPC from a technical tool into an engineering discipline. Contract changes go through review. Breaking changes are caught by the compiler. Every team generates their own strongly typed client from the same source of truth and stays in sync automatically, regardless of programming language.</p>
<p>In Flutter specifically, gRPC server streams integrate naturally with Dart's <code>Stream</code> type and Riverpod's stream providers. Real-time balance updates and live transaction feeds that would require polling with REST or a separate WebSocket implementation become simple stream subscriptions. The server pushes, the widget listens, and nothing else is required.</p>
<p>The decision of when to use gRPC versus REST versus WebSockets isn't about preference. It's about matching the tool to the requirement. Public APIs belong behind REST. High-frequency internal service communication belongs on gRPC. Large payloads consumed by many internal systems belong in protobuf. Real-time bidirectional features belong on gRPC streaming. Users on variable networks deserve the smallest payloads you can give them.</p>
<p>Understanding all of these tools, understanding why they exist, and knowing when to reach for each one is what separates engineers who use tools from engineers who think in systems.</p>
<p>Happy Coding!</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>
        
            <item>
                <title>
                    <![CDATA[ How to Use DartExceptor: A Lighter Way to Handle Errors in Dart 3 ]]>
                </title>
                <description>
                    <![CDATA[ If you've worked with Flutter for any meaningful length of time, you've likely written this: try {   final user = await repo.getUser();   print(user.name); } catch (e) {   print('Something went wrong: ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-dartexceptor-a-lighter-way-to-handle-errors-in-dart-3/</link>
                <guid isPermaLink="false">6a32f2e011341f4f7a8c6b83</guid>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ error handling ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Wed, 17 Jun 2026 19:17:52 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/cc40f61f-a62e-42f6-b644-6a3742f60714.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've worked with Flutter for any meaningful length of time, you've likely written this:</p>
<pre><code class="language-dart">try {
  final user = await repo.getUser();
  print(user.name);
} catch (e) {
  print('Something went wrong: $e');
}
</code></pre>
<p>It compiles. It ships. And six months later, a bug report lands from a user staring at a blank screen, because somewhere, a <code>catch (e)</code> swallowed the real failure.</p>
<p>This snippet looks harmless, but it has three problems that only surface under pressure.</p>
<p>First, the failure is invisible in the signature. Whatever <code>repo.getUser()</code> returns tells you nothing about what happens when the network drops, the token expires, or the response is malformed. You only find out by reading the implementation, or by hitting the bug in production.</p>
<p>Second, the compiler can't help you. If a teammate forgets the <code>try/catch</code> somewhere else in the codebase, the app compiles fine. Nothing warns you. The crash happens at runtime, in front of a real user, not at build time in front of you.</p>
<p>Third, <code>catch (e)</code> catches everything indiscriminately. A typo, a null dereference, an actual network failure, and a malformed JSON response all land in the same block. You can't tell them apart without inspecting the error string, and that's fragile since it breaks the moment the message changes.</p>
<p>Put together, every failure path becomes a social contract between a function's author and its caller instead of something the type system enforces. Social contracts break under pressure, in large teams, and at 2am during an incident.</p>
<p>A few weeks ago, I wrote <a href="https://www.freecodecamp.org/news/advanced-error-handling-in-dart-records-result-types-monads-and-freezed-exceptions/">Advanced Error Handling in Dart: Records, Result Types, Monads, and Freezed Exceptions</a> to walk through fixing exactly this, using Records, sealed Result types, the Monad pattern, <code>dartz</code>, and Freezed exceptions to make failure typed, visible, and impossible to ignore.</p>
<p>This article is meant to stand on its own, so we'll start with a quick recap of where that one landed before we pick the thread back up.</p>
<h3 id="heading-what-well-cover">What We'll Cover:</h3>
<ol>
<li><p><a href="#heading-recap-where-the-previous-article-left-off">Recap: Where the Previous Article Left Off</a></p>
</li>
<li><p><a href="#heading-the-problem-after-the-pattern">The Problem After the Pattern</a></p>
</li>
<li><p><a href="#heading-how-dart-exceptor-works">How DartExceptor Works</a></p>
</li>
<li><p><a href="#heading-the-core-type">The Core Type</a></p>
</li>
<li><p><a href="#heading-the-api-four-methods-each-with-one-job">The API: Four Methods, Each With One Job</a></p>
</li>
<li><p><a href="#heading-where-this-fits-in-clean-architecture">Where This Fits in Clean Architecture</a></p>
</li>
<li><p><a href="#heading-why-not-just-use-dartz">Why Not Just Use dartz?</a></p>
</li>
<li><p><a href="#heading-try-it-out">Try it Out</a></p>
</li>
</ol>
<h2 id="heading-recap-where-the-previous-article-left-off">Recap: Where the Previous Article Left Off</h2>
<p>That article moved through several layers, each one fixing a limitation in the layer before it.</p>
<p>It started with Dart Records as the simplest possible fix, a typed tuple with nullable fields for success and failure:</p>
<pre><code class="language-dart">typedef Result&lt;E, T&gt; = ({E? e, T? data});
</code></pre>
<p>This is already better than a bare exception because the return type now admits a function can fail.</p>
<p>But records have a real limitation. Nothing stops you from forgetting to check which field is populated, and there's no way to transform a result without manually unwrapping it first.</p>
<p>That gap is what led to a proper sealed Result type, <code>AppResult&lt;T&gt;</code>, which replaces the nullable-field record with two structurally distinct subclasses, <code>AppSuccess</code> and <code>AppFailure</code>, plus a <code>when()</code> method that forces both cases to be handled:</p>
<pre><code class="language-dart">sealed class AppResult&lt;T&gt; {
  const AppResult();

  R when&lt;R&gt;({
    required R Function(T value) success,
    required R Function(AppFailure failure) failure,
  });
}

class AppSuccess&lt;T&gt; extends AppResult&lt;T&gt; {
  const AppSuccess(this.value);
  final T value;

  @override
  R when&lt;R&gt;({
    required R Function(T value) success,
    required R Function(AppFailure failure) failure,
  }) =&gt; success(value);
}

class AppFailure&lt;T&gt; extends AppResult&lt;T&gt; {
  const AppFailure(this.error);
  final AppError error;

  @override
  R when&lt;R&gt;({
    required R Function(T value) success,
    required R Function(AppFailure failure) failure,
  }) =&gt; failure(this);
}
</code></pre>
<p>Because <code>AppResult</code> is <code>sealed</code>, the compiler enforces exhaustiveness. You genuinely can't forget the failure branch the way you could with a record or a <code>try/catch</code>.</p>
<p>From there, the article extended <code>AppResult</code> into a proper Monad by adding <code>map</code> and <code>flatMap</code>, so results could be transformed and chained without ever leaving the wrapper, and brought in <code>dartz</code>'s <code>Either</code> as the more conventional functional programming equivalent for teams who wanted that vocabulary. It closed with Freezed-based typed exceptions, so even the failure side carried structured, pattern-matchable data instead of a bare string.</p>
<p>By the end, the pattern looked like this across a full stack: a sealed result type, structured exceptions, and <code>map</code>/<code>flatMap</code> for transformation, wired consistently through the repository, domain, and presentation layers.</p>
<p>If you want the full derivation, why each layer was added, the <code>dartz</code> integration, and the Freezed exception setup, that article covers it in depth. What follows here only assumes the shape above, not the journey to it.</p>
<h2 id="heading-the-problem-after-the-pattern">The Problem After the Pattern</h2>
<p>Here's what happened after I published that article.</p>
<p>Every time I started a new project, I found myself doing the same thing: recreating the sealed <code>Result</code> class, rewriting <code>Ok</code> and <code>Err</code>, re-implementing <code>map</code>, <code>flatMap</code>, and the rest. Copying the same roughly 150 lines from project to project, tweaking small things, occasionally introducing inconsistencies between projects because I forgot what I'd named something last time.</p>
<p>The pattern was right. The repetition wasn't.</p>
<p>A pattern you have to rewrite every time isn't a pattern, it's a chore. So I packaged it.</p>
<h2 id="heading-how-dartexceptor-works">How DartExceptor Works</h2>
<p><a href="https://pub.dev/packages/dart_exceptor"><strong>DartExceptor</strong></a> is a lightweight, zero-dependency Dart 3 package that implements the exact pattern from the previous article, <code>Trace&lt;T, E&gt;</code>, <code>Ok</code>, <code>Err</code>, and a small, intentional set of monadic operations, as a reusable package.</p>
<p>No <code>dartz</code>, no Freezed, and no build_runner. Just <code>Trace&lt;T, E&gt;</code>, two implementations, and four methods.</p>
<pre><code class="language-dart">dependencies:
  dart_exceptor: ^1.1.2
</code></pre>
<pre><code class="language-dart">import 'package:dart_exceptor/dart_exceptor.dart';
</code></pre>
<p>That's the entire setup.</p>
<h2 id="heading-the-core-type">The Core Type</h2>
<p>Every operation in DartExceptor returns a <code>Trace&lt;T, E&gt;</code>:</p>
<ul>
<li><p><code>T</code> is the success type</p>
</li>
<li><p><code>E</code> is the error type</p>
</li>
</ul>
<p><code>Trace</code> has exactly two implementations:</p>
<pre><code class="language-dart">return Ok(user);                                    // success
return Err(AppException(code: 404, e: 'Not found')); // failure
</code></pre>
<p>You never construct <code>Trace</code> directly. You return <code>Ok</code> or <code>Err</code>, and program against <code>Trace</code> everywhere else. The function signature now tells the truth about what can happen:</p>
<pre><code class="language-dart">Future&lt;Trace&lt;User, AppException&gt;&gt; getUser(String id);
</code></pre>
<p>Anyone reading that signature immediately knows this can succeed with a <code>User</code>, or fail with an <code>AppException</code>. No surprises six months later.</p>
<h2 id="heading-the-api-four-methods-each-with-one-job">The API: Four Methods, Each With One Job</h2>
<p>If the previous article's <code>Result</code> type had <code>map</code>, <code>flatMap</code>, and a <code>when()</code> for pattern matching, DartExceptor takes that same shape and refines it into four focused methods.</p>
<h3 id="heading-split-the-exit-point"><code>split</code>, the Exit Point</h3>
<p><code>split</code> is where you leave the <code>Trace</code> world. Both handlers are required, so you can't accidentally ignore a failure path.</p>
<pre><code class="language-dart">result.split(
  data: (user) =&gt; print(user.name),
  e: (e) =&gt; print(e.message),
);
</code></pre>
<h3 id="heading-map-extract-and-transform-success"><code>map</code>, Extract and Transform Success</h3>
<p><code>map</code> unwraps the value from an <code>Ok</code> and lets you transform it directly:</p>
<pre><code class="language-dart">final activeUsers = result.map(
  data: (users) =&gt; users.where((u) =&gt; u.isActive).toList(),
);
</code></pre>
<h3 id="heading-maperror-extract-and-transform-failure"><code>mapError</code>, Extract and Transform Failure</h3>
<p>This is the mirror of <code>map</code>, for the error side. It's useful when crossing architectural boundaries where your data layer's exception type differs from your domain layer's:</p>
<pre><code class="language-dart">final domainError = result.mapError(
  e: (e) =&gt; AppException(code: e.statusCode, e: e.toString()),
);
</code></pre>
<h3 id="heading-bind-chain-operations-that-return-trace"><code>bind&lt;B&gt;</code>, Chain Operations That Return <code>Trace</code></h3>
<p>This is the one that does the real work. <code>bind&lt;B&gt;</code> lets you chain operations that themselves return a <code>Trace</code>, transforming the success type at each step. If any step fails, everything downstream is skipped automatically.</p>
<pre><code class="language-dart">result
    .bind&lt;User&gt;(
      n: (users) {
        try {
          return Ok(users.firstWhere((u) =&gt; u.id == id));
        } catch (e) {
          return Err(AppException(code: 404, e: 'User not found'));
        }
      },
    )
    .bind&lt;String&gt;(n: (user) =&gt; Ok(user.firstName))
    .split(
      data: (name) =&gt; print('User: $name'),
      e: (e) =&gt; print('Error: ${e.e}'),
    );
</code></pre>
<p><code>List&lt;User&gt;</code> becomes <code>User</code> becomes <code>String</code>. Each <code>bind&lt;B&gt;</code> transforms the type, the compiler checks every step, and a failure anywhere in the chain short-circuits straight to the <code>e</code> handler in <code>split</code>. This is the previous article's <code>flatMap</code> discussion, taken to its logical conclusion.</p>
<h2 id="heading-where-this-fits-in-clean-architecture">Where This Fits in Clean Architecture</h2>
<p>The pattern from the original article was always about more than syntax. It was about making failure visible across layers. DartExceptor slots into that exact structure with zero modification:</p>
<pre><code class="language-dart">// Data layer
abstract class DataSource {
  Future&lt;Trace&lt;List&lt;User&gt;, AppException&gt;&gt; getAllUsers();
}

// Repository layer
abstract class IUserRepository {
  Future&lt;Trace&lt;List&lt;User&gt;, AppException&gt;&gt; getAllUsers();
}

// Use case layer
class UserUseCase {
  Future&lt;Trace&lt;List&lt;User&gt;, AppException&gt;&gt; getAllUsers() =&gt; repository.getAllUsers();
}

// Presentation layer
void loadUsers() async {
  final result = await useCase.getAllUsers();

  result.split(
    data: (users) =&gt; print('Loaded ${users.length} users'),
    e: (e) =&gt; print('Failed: ${e.e}'),
  );
}
</code></pre>
<p>The same layers, same separation, and same typed failure paths, just without rewriting the foundation every time.</p>
<h2 id="heading-why-not-just-use-dartz">Why Not Just Use <code>dartz</code>?</h2>
<p>The previous article covered <code>dartz</code>'s <code>Either</code> in depth, and it's a genuinely solid choice if your team is comfortable with its API surface and the dependency footprint isn't a concern.</p>
<p>DartExceptor exists for a narrower case, when you want the result type pattern without importing a library built around Haskell-style functional programming conventions. Theres no <code>Left</code>/<code>Right</code>, no <code>fold</code>, and no transitive dependencies. Just <code>Trace</code>, <code>Ok</code>, <code>Err</code>, and four methods that map directly onto how the previous article's pattern was actually used in practice.</p>
<table>
<thead>
<tr>
<th></th>
<th>DartExceptor</th>
<th>dartz</th>
</tr>
</thead>
<tbody><tr>
<td>Dependencies</td>
<td>Zero</td>
<td>Multiple</td>
</tr>
<tr>
<td>Dart 3 native</td>
<td>Yes</td>
<td>No</td>
</tr>
<tr>
<td>API surface</td>
<td>4 methods</td>
<td>Large</td>
</tr>
<tr>
<td>Haskell concepts required</td>
<td>No</td>
<td>Yes</td>
</tr>
<tr>
<td>Type-safe chaining (<code>bind&lt;B&gt;</code>)</td>
<td>Yes</td>
<td>Yes (<code>flatMap</code>)</td>
</tr>
</tbody></table>
<h2 id="heading-try-it-out">Try It Out</h2>
<p>DartExceptor is live on pub.dev:</p>
<pre><code class="language-dart">dependencies:
  dart_exceptor: ^1.1.2
</code></pre>
<p>Package: <a href="https://pub.dev/packages/dart_exceptor">pub.dev/packages/dart_exceptor</a> Source: <a href="https://github.com/seyifunmi92/Dart-Exceptor-Plugin">GitHub</a></p>
<p>If you've read the previous article and built something like this yourself, I'd genuinely love to hear how your version compares. And if DartExceptor saves you from rewriting that pattern one more time, a star on GitHub goes a long way.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ From Flutter to Backend: How to Build Production-Grade REST APIs with Dart and Dart Frog ]]>
                </title>
                <description>
                    <![CDATA[ Dart backend frameworks exist on a spectrum. At the minimal end sits Shelf, with raw primitives and full control. You wire everything yourself. At the maximal end sits Serverpod. It's a full framework ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-production-grade-rest-apis-with-dart-and-dart-frog/</link>
                <guid isPermaLink="false">6a2b553bb84c3c44ce471560</guid>
                
                    <category>
                        <![CDATA[ dart_frog ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ backend ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Fri, 12 Jun 2026 00:39:23 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/a80b24db-c53e-4d36-85cd-0cb999676145.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Dart backend frameworks exist on a spectrum. At the <a href="https://www.freecodecamp.org/news/how-to-build-and-ship-production-rest-apis-with-dart-and-shelf/">minimal end sits Shelf,</a> with raw primitives and full control. You wire everything yourself. <a href="https://www.freecodecamp.org/news/how-to-build-production-grade-rest-apis-with-dart-and-serverpod/">At the maximal end sits Serverpod</a>. It's a full framework with code generation and opinionated conventions. The framework makes most structural decisions for you.</p>
<p>Dart Frog lives in the middle, and for many Flutter engineers, it's the most natural fit.</p>
<p>Dart Frog is a fast, minimalistic backend framework built on top of Shelf, originally created by Very Good Ventures and now maintained independently. It takes the file-based routing model popularized by Next.js and Remix, applies it to Dart, and wraps it with a clean CLI that handles development server, hot reload, production builds, and Docker generation, all out of the box.</p>
<p>You write a Dart file in the routes/ directory, export an onRequest function, and Dart Frog handles the routing automatically. No router configuration, no handler registration, no mounting. The file system is the router.</p>
<p>In this article, we'll build a User and Profile Management REST API (the same one we built in the linked articles above) using Dart Frog, connect it to PostgreSQL, add JWT authentication, and deploy it to Fly.io.</p>
<p>By the end you'll understand Dart Frog's routing model deeply, and you'll have a clear picture of where it fits compared to Shelf and Serverpod.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-dart-frog-differs-from-shelf-and-serverpod">How Dart Frog Differs from Shelf and Serverpod</a></p>
</li>
<li><p><a href="#heading-installing-dart-frog">Installing Dart Frog</a></p>
</li>
<li><p><a href="#heading-creating-the-project">Creating the Project</a></p>
</li>
<li><p><a href="#heading-understanding-the-project-structure">Understanding the Project Structure</a></p>
</li>
<li><p><a href="#heading-dart-frog-core-concepts">Dart Frog Core Concepts</a></p>
<ul>
<li><p><a href="#heading-file-based-routing">File-Based Routing</a></p>
</li>
<li><p><a href="#heading-the-requestcontext">The RequestContext</a></p>
</li>
<li><p><a href="#heading-middleware-and-dependency-injection">Middleware and Dependency Injection</a></p>
</li>
<li><p><a href="#heading-dynamic-routes">Dynamic Routes</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-setting-up-the-database">Setting Up the Database</a></p>
<ul>
<li><p><a href="#heading-docker-compose-for-postgresql">Docker Compose for PostgreSQL</a></p>
</li>
<li><p><a href="#heading-environment-configuration">Environment Configuration</a></p>
</li>
<li><p><a href="#heading-database-connection-manager">Database Connection Manager</a></p>
</li>
<li><p><a href="#heading-migrations">Migrations</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-defining-the-models">Defining the Models</a></p>
</li>
<li><p><a href="#heading-building-the-repositories">Building the Repositories</a></p>
<ul>
<li><p><a href="#heading-user-repository">User Repository</a></p>
</li>
<li><p><a href="#heading-profile-repository">Profile Repository</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-authentication-service">Authentication Service</a></p>
</li>
<li><p><a href="#heading-middleware">Middleware</a></p>
<ul>
<li><p><a href="#heading-database-middleware">Database Middleware</a></p>
</li>
<li><p><a href="#heading-auth-middleware">Auth Middleware</a></p>
</li>
<li><p><a href="#heading-error-middleware">Error Middleware</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-building-the-routes">Building the Routes</a></p>
<ul>
<li><p><a href="#heading-auth-routes">Auth Routes</a></p>
</li>
<li><p><a href="#heading-user-routes">User Routes</a></p>
</li>
<li><p><a href="#heading-profile-routes">Profile Routes</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-wiring-the-middleware-pipeline">Wiring the Middleware Pipeline</a></p>
</li>
<li><p><a href="#heading-testing-the-api">Testing the API</a></p>
</li>
<li><p><a href="#heading-deployment">Deployment</a></p>
<ul>
<li><p><a href="#heading-production-build">Production Build</a></p>
</li>
<li><p><a href="#heading-deploying-to-flyio">Deploying to Fly.io</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before starting, you should have:</p>
<ul>
<li><p>Comfortable familiarity with Dart and Flutter development</p>
</li>
<li><p>Understanding of REST API concepts, endpoints, HTTP methods, status codes</p>
</li>
<li><p>Docker Desktop installed and running</p>
</li>
<li><p>A Fly.io account for deployment</p>
</li>
</ul>
<h2 id="heading-how-dart-frog-differs-from-shelf-and-serverpod">How Dart Frog Differs from Shelf and Serverpod</h2>
<p>Understanding where Dart Frog sits in relation to the other two frameworks helps you make the right choice for each project.</p>
<p>Shelf gives you a Router and you mount handlers manually. Your folder structure has nothing to do with your URL structure. You decide what goes where.</p>
<p>Serverpod generates your routes from endpoint class names and method names. You define a class, run a generator, and the URL is derived automatically.</p>
<p>Dart Frog maps your file system directly to your URL structure. A file at routes/users/index.dart becomes the /users endpoint. A file at routes/users/[id].dart becomes /users/:id. No configuration, no registration, no generation step. The file is the route.</p>
<p>This model will feel immediately intuitive to Flutter engineers who have worked with Next.js or any modern web framework. It's also significantly easier to navigate in a team. You look at the folder structure and you instantly know what endpoints exist.</p>
<p>The other key difference is the RequestContext. Where Shelf passes a raw Request to handlers, Dart Frog wraps it in a RequestContext that carries both the request and any values injected by middleware. This is Dart Frog's dependency injection mechanism, and it's elegant.</p>
<h2 id="heading-installing-dart-frog">Installing Dart Frog</h2>
<p>Install the Dart Frog CLI:</p>
<pre><code class="language-bash">dart pub global activate dart_frog_cli
</code></pre>
<p>Verify the installation:</p>
<pre><code class="language-bash">dart_frog --version
</code></pre>
<h2 id="heading-creating-the-project">Creating the Project</h2>
<pre><code class="language-bash">dart_frog create user_profile_api
cd user_profile_api
</code></pre>
<p>Start the development server with hot reload:</p>
<pre><code class="language-bash">dart_frog dev
</code></pre>
<p>Visit <a href="http://localhost:8080">http://localhost:8080</a> and you'll see the default welcome response. The dev server watches for file changes and reloads automatically. No restart needed as you build.</p>
<h2 id="heading-understanding-the-project-structure">Understanding the Project Structure</h2>
<pre><code class="language-plaintext">user_profile_api/
  routes/
    index.dart              ← GET /
  pubspec.yaml
  analysis_options.yaml
</code></pre>
<p>That's the entire starting structure. Clean and minimal. Everything we add will extend from here.</p>
<p>After building our API, the full structure will look like this:</p>
<pre><code class="language-plaintext">user_profile_api/
  routes/
    _middleware.dart         ← global middleware pipeline
    index.dart               ← GET /
    auth/
      login.dart             ← POST /auth/login
      register.dart          ← POST /auth/register
    users/
      index.dart             ← GET /users
      [id].dart              ← GET, PUT, DELETE /users/:id
      [id]/
        profile.dart         ← GET, POST, PUT /users/:id/profile
  lib/
    config/
      database.dart
      env.dart
    models/
      user.dart
      profile.dart
    repositories/
      user_repository.dart
      profile_repository.dart
    services/
      auth_service.dart
    middleware/
      auth_middleware.dart
      error_middleware.dart
  pubspec.yaml
</code></pre>
<p>The routes/ folder is the heart of a Dart Frog project. The lib/ folder holds all shared logic that routes import. This separation is clean and deliberate: routing concerns live in routes/, while business logic lives in lib/.</p>
<h2 id="heading-dart-frog-core-concepts">Dart Frog Core Concepts</h2>
<h3 id="heading-file-based-routing">File-Based Routing</h3>
<p>Every .dart file in the routes/ directory is a route. The file path determines the URL path:</p>
<table>
<thead>
<tr>
<th>File</th>
<th>URL</th>
</tr>
</thead>
<tbody><tr>
<td>routes/index.dart</td>
<td>/</td>
</tr>
<tr>
<td>routes/users/index.dart</td>
<td>/users</td>
</tr>
<tr>
<td>routes/users/[id].dart</td>
<td>/users/:id</td>
</tr>
<tr>
<td>routes/auth/login.dart</td>
<td>/auth/login</td>
</tr>
<tr>
<td>routes/users/[id]/profile.dart</td>
<td>/users/:id/profile</td>
</tr>
</tbody></table>
<p>Every route file must export an onRequest function:</p>
<pre><code class="language-dart">import 'package:dart_frog/dart_frog.dart';

Future&lt;Response&gt; onRequest(RequestContext context) async {
  return Response.json(body: {'message': 'Hello from Dart Frog'});
}
</code></pre>
<p>That's the entire contract. One function, one file, one route. Dart Frog generates the internal routing glue automatically when you run dart_frog dev or dart_frog build.</p>
<h3 id="heading-the-requestcontext">The RequestContext</h3>
<p>RequestContext is the object passed to every route handler and middleware. It's more than just the HTTP request: it's a container for the request and any values that middleware has injected:</p>
<pre><code class="language-dart">Future&lt;Response&gt; onRequest(RequestContext context) async {
  // The raw HTTP request
  final request = context.request;

  // HTTP method
  print(request.method); // GET, POST, etc.

  // Path parameters (for dynamic routes like [id].dart)
  final id = context.request.uri.pathSegments.last;

  // Query parameters
  final page = request.uri.queryParameters['page'];

  // Request body
  final body = await request.json() as Map&lt;String, dynamic&gt;;

  // Values injected by middleware
  final db = context.read&lt;DatabaseConnection&gt;();
  final currentUser = context.read&lt;AuthenticatedUser&gt;();

  return Response.json(body: {'ok': true});
}
</code></pre>
<p>context.read() is the dependency injection mechanism. Middleware provides values, and routes consume them. This keeps routes clean and testable: a route handler doesn't know how a database connection was created, it just reads it from context.</p>
<h3 id="heading-middleware-and-dependency-injection">Middleware and Dependency Injection</h3>
<p>A <code>_middleware.dart</code> file in any route folder applies middleware to all routes in that folder and its subfolders. A <code>_middleware.dart</code> at the root routes/ level applies globally.</p>
<p>Middleware in Dart Frog uses the provider pattern to inject values into the context:</p>
<pre><code class="language-dart">import 'package:dart_frog/dart_frog.dart';

Handler middleware(Handler handler) {
  return handler.use(
    provider&lt;DatabaseConnection&gt;(
      (context) =&gt; DatabaseConnection.instance,
    ),
  );
}
</code></pre>
<p>Any route in the same folder, or any subfolder, can then call context.read() to get the connection. No global singletons, no manual passing. The context carries it.</p>
<p>Middleware functions can also intercept requests before they reach the route handler, making them perfect for authentication:</p>
<pre><code class="language-dart">Handler middleware(Handler handler) {
  return (context) async {
    final authHeader = context.request.headers['authorization'];

    if (authHeader == null) {
      return Response.json(
        statusCode: 401,
        body: {'error': 'Authorization required'},
      );
    }

    // Verify token and inject user
    final user = verifyToken(authHeader);
    return handler(context.provide&lt;AuthenticatedUser&gt;(() =&gt; user));
  };
}
</code></pre>
<h3 id="heading-dynamic-routes">Dynamic Routes</h3>
<p>A file named [id].dart matches any single path segment. Inside the handler, extract the parameter from the URL:</p>
<pre><code class="language-dart">Future&lt;Response&gt; onRequest(RequestContext context, String id) async {
  // id is automatically passed as a parameter for dynamic routes
  return Response.json(body: {'userId': id});
}
</code></pre>
<p>Dart Frog passes dynamic route parameters as additional arguments to onRequest. This is cleaner than parsing them manually from the URL.</p>
<h2 id="heading-setting-up-the-database">Setting Up the Database</h2>
<h3 id="heading-docker-compose-for-postgresql">Docker Compose for PostgreSQL</h3>
<p>Create docker-compose.yml in the project root:</p>
<pre><code class="language-yaml">version: '3.8'

services:
  postgres:
    image: postgres:16-alpine
    container_name: user_profile_db
    environment:
      POSTGRES_DB: user_profile_api
      POSTGRES_USER: dart_user
      POSTGRES_PASSWORD: dart_password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U dart_user -d user_profile_api"]
      interval: 5s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:
</code></pre>
<p>Start the database:</p>
<pre><code class="language-bash">docker compose up -d
</code></pre>
<h3 id="heading-environment-configuration">Environment Configuration</h3>
<p>Add dependencies to pubspec.yaml:</p>
<pre><code class="language-yaml">dependencies:
  dart_frog: ^1.4.0
  dart_frog_auth: ^0.1.0
  postgres: ^3.3.0
  dart_jsonwebtoken: ^2.12.0
  bcrypt: ^1.1.3
  dotenv: ^4.1.0

dev_dependencies:
  dart_frog_cli: ^1.2.0
  test: ^1.24.0
  dart_frog_test: ^0.1.0
</code></pre>
<p>Run dart pub get.</p>
<p>Create .env:</p>
<pre><code class="language-plaintext">DB_HOST=localhost
DB_PORT=5432
DB_NAME=user_profile_api
DB_USER=dart_user
DB_PASSWORD=dart_password
JWT_SECRET=your_super_secret_key_change_this_in_production
JWT_EXPIRY_HOURS=24
PORT=8080
</code></pre>
<p>Create lib/config/env.dart:</p>
<pre><code class="language-dart">import 'package:dotenv/dotenv.dart';

class Env {
  static late final DotEnv _env;

  static void load() {
    _env = DotEnv(includePlatformEnvironment: true)..load();
  }

  static String get dbHost =&gt; _env['DB_HOST'] ?? 'localhost';
  static int get dbPort =&gt; int.parse(_env['DB_PORT'] ?? '5432');
  static String get dbName =&gt; _env['DB_NAME'] ?? 'user_profile_api';
  static String get dbUser =&gt; _env['DB_USER'] ?? 'dart_user';
  static String get dbPassword =&gt; _env['DB_PASSWORD'] ?? '';
  static String get jwtSecret =&gt; _env['JWT_SECRET'] ?? '';
  static int get jwtExpiryHours =&gt;
      int.parse(_env['JWT_EXPIRY_HOURS'] ?? '24');
}
</code></pre>
<h3 id="heading-database-connection-manager">Database Connection Manager</h3>
<p>Create lib/config/database.dart:</p>
<pre><code class="language-dart">import 'package:postgres/postgres.dart';
import 'env.dart';

class Database {
  static Connection? _connection;

  static Future&lt;Connection&gt; get connection async {
    if (_connection != null) return _connection!;
    _connection = await Connection.open(
      Endpoint(
        host: Env.dbHost,
        port: Env.dbPort,
        database: Env.dbName,
        username: Env.dbUser,
        password: Env.dbPassword,
      ),
      settings: const ConnectionSettings(sslMode: SslMode.disable),
    );
    print('Database connected');
    return _connection!;
  }

  static Future&lt;void&gt; runMigrations() async {
    final conn = await connection;
    await conn.execute('''
      CREATE TABLE IF NOT EXISTS users (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        email VARCHAR(255) UNIQUE NOT NULL,
        password_hash VARCHAR(255) NOT NULL,
        first_name VARCHAR(100) NOT NULL,
        last_name VARCHAR(100) NOT NULL,
        is_active BOOLEAN DEFAULT TRUE,
        created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
        updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
      );

      CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);

      CREATE TABLE IF NOT EXISTS profiles (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
        bio TEXT,
        avatar_url VARCHAR(500),
        phone VARCHAR(20),
        location VARCHAR(255),
        website VARCHAR(500),
        created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
        updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
        UNIQUE(user_id)
      );

      CREATE INDEX IF NOT EXISTS idx_profiles_user_id ON profiles(user_id);
    ''');
    print('Migrations applied');
  }
}
</code></pre>
<h3 id="heading-migrations">Migrations</h3>
<p>Dart Frog projects have a main.dart entry point generated during dart_frog build. For the development server, migrations are best run from the project entrypoint. Create main.dart in the project root:</p>
<pre><code class="language-dart">import 'dart:io';
import 'package:dart_frog/dart_frog.dart';
import 'lib/config/database.dart';
import 'lib/config/env.dart';

Future&lt;HttpServer&gt; run(Handler handler, InternetAddress ip, int port) async {
  Env.load();
  await Database.runMigrations();
  return serve(handler, ip, port);
}
</code></pre>
<p>This run function is Dart Frog's server lifecycle hook. It runs before the server starts accepting requests, giving us the right place to load environment variables and run migrations.</p>
<h2 id="heading-defining-the-models">Defining the Models</h2>
<p>With the database layer in place, we need Dart classes to represent the data coming in and out of it.</p>
<p>The User model maps to the users table and handles conversion between database rows and Dart objects. The Profile model does the same for the profiles table. Both models follow the same pattern: a factory constructor for reading from the database and a <code>toJson</code> method for sending data back to the client.</p>
<p>Note that <code>toJson</code> on the User model deliberately excludes the password hash. You should never return credential data in an API response.</p>
<p>Create lib/models/user.dart:</p>
<pre><code class="language-dart">class User {
  const User({
    required this.id,
    required this.email,
    required this.passwordHash,
    required this.firstName,
    required this.lastName,
    required this.isActive,
    required this.createdAt,
    required this.updatedAt,
  });

  final String id;
  final String email;
  final String passwordHash;
  final String firstName;
  final String lastName;
  final bool isActive;
  final DateTime createdAt;
  final DateTime updatedAt;

  factory User.fromRow(Map&lt;String, dynamic&gt; row) =&gt; User(
        id: row['id'] as String,
        email: row['email'] as String,
        passwordHash: row['password_hash'] as String,
        firstName: row['first_name'] as String,
        lastName: row['last_name'] as String,
        isActive: row['is_active'] as bool,
        createdAt: row['created_at'] as DateTime,
        updatedAt: row['updated_at'] as DateTime,
      );

  Map&lt;String, dynamic&gt; toJson() =&gt; {
        'id': id,
        'email': email,
        'firstName': firstName,
        'lastName': lastName,
        'isActive': isActive,
        'createdAt': createdAt.toIso8601String(),
        'updatedAt': updatedAt.toIso8601String(),
      };
}
</code></pre>
<p>Create lib/models/profile.dart:</p>
<pre><code class="language-dart">class Profile {
  const Profile({
    required this.id,
    required this.userId,
    this.bio,
    this.avatarUrl,
    this.phone,
    this.location,
    this.website,
    required this.createdAt,
    required this.updatedAt,
  });

  final String id;
  final String userId;
  final String? bio;
  final String? avatarUrl;
  final String? phone;
  final String? location;
  final String? website;
  final DateTime createdAt;
  final DateTime updatedAt;

  factory Profile.fromRow(Map&lt;String, dynamic&gt; row) =&gt; Profile(
        id: row['id'] as String,
        userId: row['user_id'] as String,
        bio: row['bio'] as String?,
        avatarUrl: row['avatar_url'] as String?,
        phone: row['phone'] as String?,
        location: row['location'] as String?,
        website: row['website'] as String?,
        createdAt: row['created_at'] as DateTime,
        updatedAt: row['updated_at'] as DateTime,
      );

  Map&lt;String, dynamic&gt; toJson() =&gt; {
        'id': id,
        'userId': userId,
        'bio': bio,
        'avatarUrl': avatarUrl,
        'phone': phone,
        'location': location,
        'website': website,
        'createdAt': createdAt.toIso8601String(),
        'updatedAt': updatedAt.toIso8601String(),
      };
}
</code></pre>
<h2 id="heading-building-the-repositories">Building the Repositories</h2>
<p>Repositories are the single point of contact between the application and the database. Rather than writing SQL directly inside route handlers, we'll centralise all database operations here. This keeps the handlers clean and makes the data access logic easy to find, maintain, and test independently.</p>
<p>The UserRepository handles every operation on the users table. The ProfileRepository does the same for profiles, using userId as its primary lookup key since profiles are always accessed in the context of a specific user.</p>
<h3 id="heading-user-repository">User Repository</h3>
<p>Create lib/repositories/user_repository.dart:</p>
<pre><code class="language-dart">import 'package:postgres/postgres.dart';
import '../config/database.dart';
import '../models/user.dart';

class UserRepository {
  Future&lt;Connection&gt; get _conn =&gt; Database.connection;

  Future&lt;List&lt;User&gt;&gt; findAll() async {
    final conn = await _conn;
    final results = await conn.execute(
      'SELECT * FROM users WHERE is_active = TRUE ORDER BY created_at DESC',
    );
    return results.map((r) =&gt; User.fromRow(r.toColumnMap())).toList();
  }

  Future&lt;User?&gt; findById(String id) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('SELECT * FROM users WHERE id = @id AND is_active = TRUE'),
      parameters: {'id': id},
    );
    if (results.isEmpty) return null;
    return User.fromRow(results.first.toColumnMap());
  }

  Future&lt;User?&gt; findByEmail(String email) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('SELECT * FROM users WHERE email = @email'),
      parameters: {'email': email},
    );
    if (results.isEmpty) return null;
    return User.fromRow(results.first.toColumnMap());
  }

  Future&lt;User&gt; create({
    required String email,
    required String passwordHash,
    required String firstName,
    required String lastName,
  }) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('''
        INSERT INTO users (email, password_hash, first_name, last_name)
        VALUES (@email, @passwordHash, @firstName, @lastName)
        RETURNING *
      '''),
      parameters: {
        'email': email,
        'passwordHash': passwordHash,
        'firstName': firstName,
        'lastName': lastName,
      },
    );
    return User.fromRow(results.first.toColumnMap());
  }

  Future&lt;User?&gt; update({
    required String id,
    String? firstName,
    String? lastName,
  }) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('''
        UPDATE users
        SET
          first_name = COALESCE(@firstName, first_name),
          last_name  = COALESCE(@lastName, last_name),
          updated_at = NOW()
        WHERE id = @id AND is_active = TRUE
        RETURNING *
      '''),
      parameters: {'id': id, 'firstName': firstName, 'lastName': lastName},
    );
    if (results.isEmpty) return null;
    return User.fromRow(results.first.toColumnMap());
  }

  Future&lt;bool&gt; delete(String id) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('''
        UPDATE users SET is_active = FALSE, updated_at = NOW()
        WHERE id = @id AND is_active = TRUE
        RETURNING id
      '''),
      parameters: {'id': id},
    );
    return results.isNotEmpty;
  }
}
</code></pre>
<h3 id="heading-profile-repository">Profile Repository</h3>
<p>Create lib/repositories/profile_repository.dart:</p>
<pre><code class="language-dart">import 'package:postgres/postgres.dart';
import '../config/database.dart';
import '../models/profile.dart';

class ProfileRepository {
  Future&lt;Connection&gt; get _conn =&gt; Database.connection;

  Future&lt;Profile?&gt; findByUserId(String userId) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('SELECT * FROM profiles WHERE user_id = @userId'),
      parameters: {'userId': userId},
    );
    if (results.isEmpty) return null;
    return Profile.fromRow(results.first.toColumnMap());
  }

  Future&lt;Profile&gt; create({
    required String userId,
    String? bio,
    String? avatarUrl,
    String? phone,
    String? location,
    String? website,
  }) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('''
        INSERT INTO profiles (user_id, bio, avatar_url, phone, location, website)
        VALUES (@userId, @bio, @avatarUrl, @phone, @location, @website)
        RETURNING *
      '''),
      parameters: {
        'userId': userId,
        'bio': bio,
        'avatarUrl': avatarUrl,
        'phone': phone,
        'location': location,
        'website': website,
      },
    );
    return Profile.fromRow(results.first.toColumnMap());
  }

  Future&lt;Profile?&gt; update({
    required String userId,
    String? bio,
    String? avatarUrl,
    String? phone,
    String? location,
    String? website,
  }) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('''
        UPDATE profiles
        SET
          bio        = COALESCE(@bio, bio),
          avatar_url = COALESCE(@avatarUrl, avatar_url),
          phone      = COALESCE(@phone, phone),
          location   = COALESCE(@location, location),
          website    = COALESCE(@website, website),
          updated_at = NOW()
        WHERE user_id = @userId
        RETURNING *
      '''),
      parameters: {
        'userId': userId,
        'bio': bio,
        'avatarUrl': avatarUrl,
        'phone': phone,
        'location': location,
        'website': website,
      },
    );
    if (results.isEmpty) return null;
    return Profile.fromRow(results.first.toColumnMap());
  }
}
</code></pre>
<h2 id="heading-authentication-service">Authentication Service</h2>
<p>Authentication in this project is handled by a dedicated AuthService that lives in lib/services/. It has one clear responsibility: the cryptographic operations that power auth: hashing passwords before storing them, verifying passwords at login, generating signed JWT tokens on success, and verifying those tokens on protected requests.</p>
<p>Keeping this logic in a service rather than spreading it across route handlers means it can be injected via middleware and consumed cleanly anywhere in the app.</p>
<p>Create lib/services/auth_service.dart:</p>
<pre><code class="language-dart">import 'package:bcrypt/bcrypt.dart';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import '../config/env.dart';
import '../models/user.dart';

class AuthService {
  String hashPassword(String password) =&gt;
      BCrypt.hashpw(password, BCrypt.gensalt());

  bool verifyPassword(String password, String hash) =&gt;
      BCrypt.checkpw(password, hash);

  String generateToken(User user) {
    final jwt = JWT({
      'sub': user.id,
      'email': user.email,
      'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000,
    });
    return jwt.sign(
      SecretKey(Env.jwtSecret),
      expiresIn: Duration(hours: Env.jwtExpiryHours),
    );
  }

  JWT? verifyToken(String token) {
    try {
      return JWT.verify(token, SecretKey(Env.jwtSecret));
    } catch (_) {
      return null;
    }
  }
}
</code></pre>
<h2 id="heading-middleware">Middleware</h2>
<p>Middleware is where Dart Frog's dependency injection model does its most important work. Rather than instantiating repositories and services inside each route handler, we create them once in middleware and make them available to every handler downstream via the RequestContext.</p>
<p>This section defines three pieces of middleware: the database middleware that injects the repositories and auth service, the auth middleware that validates JWT tokens and protects routes, and the error middleware that catches unhandled exceptions and returns consistent error responses across the entire API.</p>
<h3 id="heading-database-middleware">Database Middleware</h3>
<p>Create lib/middleware/database_middleware.dart:</p>
<pre><code class="language-dart">import 'package:dart_frog/dart_frog.dart';
import '../repositories/user_repository.dart';
import '../repositories/profile_repository.dart';
import '../services/auth_service.dart';

Middleware databaseMiddleware() {
  return (handler) {
    return handler
        .use(provider&lt;UserRepository&gt;((_) =&gt; UserRepository()))
        .use(provider&lt;ProfileRepository&gt;((_) =&gt; ProfileRepository()))
        .use(provider&lt;AuthService&gt;((_) =&gt; AuthService()));
  };
}
</code></pre>
<p>This middleware injects the repositories and auth service into every request context. Routes read them with <code>context.read()</code> without caring how they were created.</p>
<h3 id="heading-auth-middleware">Auth Middleware</h3>
<p>Create lib/middleware/auth_middleware.dart:</p>
<pre><code class="language-dart">import 'dart:convert';
import 'package:dart_frog/dart_frog.dart';
import '../services/auth_service.dart';

Middleware authMiddleware() {
  return (handler) {
    return (context) async {
      final authHeader = context.request.headers['authorization'];

      if (authHeader == null || !authHeader.startsWith('Bearer ')) {
        return Response.json(
          statusCode: 401,
          body: {'error': 'Authorization header missing or malformed'},
        );
      }

      final token = authHeader.substring(7);
      final authService = context.read&lt;AuthService&gt;();
      final jwt = authService.verifyToken(token);

      if (jwt == null) {
        return Response.json(
          statusCode: 401,
          body: {'error': 'Invalid or expired token'},
        );
      }

      final userId = jwt.payload['sub'] as String;
      final userEmail = jwt.payload['email'] as String;

      return handler(
        context.provide&lt;Map&lt;String, String&gt;&gt;(
          () =&gt; {'userId': userId, 'userEmail': userEmail},
        ),
      );
    };
  };
}
</code></pre>
<h3 id="heading-error-middleware">Error Middleware</h3>
<p>Create lib/middleware/error_middleware.dart:</p>
<pre><code class="language-dart">import 'package:dart_frog/dart_frog.dart';

Middleware errorMiddleware() {
  return (handler) {
    return (context) async {
      try {
        return await handler(context);
      } on FormatException catch (e) {
        return Response.json(
          statusCode: 400,
          body: {'error': 'Invalid request body: ${e.message}'},
        );
      } catch (e, stackTrace) {
        print('Unhandled error: \(e\n\)stackTrace');
        return Response.json(
          statusCode: 500,
          body: {'error': 'An internal server error occurred'},
        );
      }
    };
  };
}
</code></pre>
<h2 id="heading-building-the-routes">Building the Routes</h2>
<p>With the models, repositories, auth service, and middleware all in place, we can now build the route handlers.</p>
<p>In Dart Frog, each file in the routes/ folder is a self-contained endpoint. Routes don't manage dependencies directly. Instead, they read what middleware has already injected into the context and call the appropriate repository or service method.</p>
<p>This section covers three groups of routes: the auth routes for registration and login, the user routes for CRUD operations, and the profile routes nested under a user's ID.</p>
<h3 id="heading-auth-routes">Auth Routes</h3>
<p>Create routes/auth/register.dart:</p>
<pre><code class="language-dart">import 'package:dart_frog/dart_frog.dart';
import '../../lib/repositories/user_repository.dart';
import '../../lib/services/auth_service.dart';

Future&lt;Response&gt; onRequest(RequestContext context) async {
  if (context.request.method != HttpMethod.post) {
    return Response.json(statusCode: 405, body: {'error': 'Method not allowed'});
  }

  final body = await context.request.json() as Map&lt;String, dynamic&gt;;
  final email = body['email'] as String?;
  final password = body['password'] as String?;
  final firstName = body['firstName'] as String?;
  final lastName = body['lastName'] as String?;

  if (email == null || password == null ||
      firstName == null || lastName == null) {
    return Response.json(
      statusCode: 400,
      body: {'error': 'email, password, firstName, and lastName are required'},
    );
  }

  if (password.length &lt; 8) {
    return Response.json(
      statusCode: 400,
      body: {'error': 'Password must be at least 8 characters'},
    );
  }

  final userRepo = context.read&lt;UserRepository&gt;();
  final authService = context.read&lt;AuthService&gt;();

  final existing = await userRepo.findByEmail(email);
  if (existing != null) {
    return Response.json(
      statusCode: 409,
      body: {'error': 'An account with this email already exists'},
    );
  }

  final user = await userRepo.create(
    email: email,
    passwordHash: authService.hashPassword(password),
    firstName: firstName,
    lastName: lastName,
  );

  return Response.json(
    statusCode: 201,
    body: {
      'user': user.toJson(),
      'token': authService.generateToken(user),
    },
  );
}
</code></pre>
<p>Create routes/auth/login.dart:</p>
<pre><code class="language-dart">import 'package:dart_frog/dart_frog.dart';
import '../../lib/repositories/user_repository.dart';
import '../../lib/services/auth_service.dart';

Future&lt;Response&gt; onRequest(RequestContext context) async {
  if (context.request.method != HttpMethod.post) {
    return Response.json(statusCode: 405, body: {'error': 'Method not allowed'});
  }

  final body = await context.request.json() as Map&lt;String, dynamic&gt;;
  final email = body['email'] as String?;
  final password = body['password'] as String?;

  if (email == null || password == null) {
    return Response.json(
      statusCode: 400,
      body: {'error': 'email and password are required'},
    );
  }

  final userRepo = context.read&lt;UserRepository&gt;();
  final authService = context.read&lt;AuthService&gt;();
  final user = await userRepo.findByEmail(email);

  if (user == null || !authService.verifyPassword(password, user.passwordHash)) {
    return Response.json(
      statusCode: 401,
      body: {'error': 'Invalid email or password'},
    );
  }

  return Response.json(
    body: {
      'user': user.toJson(),
      'token': authService.generateToken(user),
    },
  );
}
</code></pre>
<h3 id="heading-user-routes">User Routes</h3>
<p>Create routes/users/index.dart:</p>
<pre><code class="language-dart">import 'package:dart_frog/dart_frog.dart';
import '../../lib/repositories/user_repository.dart';

Future&lt;Response&gt; onRequest(RequestContext context) async {
  if (context.request.method != HttpMethod.get) {
    return Response.json(statusCode: 405, body: {'error': 'Method not allowed'});
  }

  final userRepo = context.read&lt;UserRepository&gt;();
  final users = await userRepo.findAll();

  return Response.json(
    body: users.map((u) =&gt; u.toJson()).toList(),
  );
}
</code></pre>
<p>Create routes/users/[id].dart:</p>
<pre><code class="language-dart">import 'package:dart_frog/dart_frog.dart';
import '../../lib/repositories/user_repository.dart';

Future&lt;Response&gt; onRequest(RequestContext context, String id) async {
  final userRepo = context.read&lt;UserRepository&gt;();

  switch (context.request.method) {
    case HttpMethod.get:
      return _getUser(userRepo, id);
    case HttpMethod.put:
      return _updateUser(context, userRepo, id);
    case HttpMethod.delete:
      return _deleteUser(userRepo, id);
    default:
      return Response.json(
        statusCode: 405,
        body: {'error': 'Method not allowed'},
      );
  }
}

Future&lt;Response&gt; _getUser(UserRepository repo, String id) async {
  final user = await repo.findById(id);
  if (user == null) {
    return Response.json(statusCode: 404, body: {'error': 'User not found'});
  }
  return Response.json(body: user.toJson());
}

Future&lt;Response&gt; _updateUser(
  RequestContext context,
  UserRepository repo,
  String id,
) async {
  final body = await context.request.json() as Map&lt;String, dynamic&gt;;
  final user = await repo.update(
    id: id,
    firstName: body['firstName'] as String?,
    lastName: body['lastName'] as String?,
  );
  if (user == null) {
    return Response.json(statusCode: 404, body: {'error': 'User not found'});
  }
  return Response.json(body: user.toJson());
}

Future&lt;Response&gt; _deleteUser(UserRepository repo, String id) async {
  final deleted = await repo.delete(id);
  if (!deleted) {
    return Response.json(statusCode: 404, body: {'error': 'User not found'});
  }
  return Response.json(statusCode: 204, body: null);
}
</code></pre>
<p>Notice how onRequest receives String id as a second parameter, Dart Frog automatically passes the dynamic path segment to the handler. The switch on context.request.method handles all HTTP methods in a single file which is the idiomatic Dart Frog pattern for CRUD endpoints.</p>
<h3 id="heading-profile-routes">Profile Routes</h3>
<p>Create routes/users/[id]/profile.dart:</p>
<pre><code class="language-dart">import 'package:dart_frog/dart_frog.dart';
import '../../../lib/repositories/user_repository.dart';
import '../../../lib/repositories/profile_repository.dart';

Future&lt;Response&gt; onRequest(RequestContext context, String id) async {
  final userRepo = context.read&lt;UserRepository&gt;();
  final profileRepo = context.read&lt;ProfileRepository&gt;();

  final user = await userRepo.findById(id);
  if (user == null) {
    return Response.json(statusCode: 404, body: {'error': 'User not found'});
  }

  switch (context.request.method) {
    case HttpMethod.get:
      return _getProfile(profileRepo, id);
    case HttpMethod.post:
      return _createProfile(context, profileRepo, id);
    case HttpMethod.put:
      return _updateProfile(context, profileRepo, id);
    default:
      return Response.json(
        statusCode: 405,
        body: {'error': 'Method not allowed'},
      );
  }
}

Future&lt;Response&gt; _getProfile(ProfileRepository repo, String userId) async {
  final profile = await repo.findByUserId(userId);
  if (profile == null) {
    return Response.json(statusCode: 404, body: {'error': 'Profile not found'});
  }
  return Response.json(body: profile.toJson());
}

Future&lt;Response&gt; _createProfile(
  RequestContext context,
  ProfileRepository repo,
  String userId,
) async {
  final existing = await repo.findByUserId(userId);
  if (existing != null) {
    return Response.json(
      statusCode: 409,
      body: {'error': 'Profile already exists for this user'},
    );
  }

  final body = await context.request.json() as Map&lt;String, dynamic&gt;;
  final profile = await repo.create(
    userId: userId,
    bio: body['bio'] as String?,
    avatarUrl: body['avatarUrl'] as String?,
    phone: body['phone'] as String?,
    location: body['location'] as String?,
    website: body['website'] as String?,
  );
  return Response.json(statusCode: 201, body: profile.toJson());
}

Future&lt;Response&gt; _updateProfile(
  RequestContext context,
  ProfileRepository repo,
  String userId,
) async {
  final body = await context.request.json() as Map&lt;String, dynamic&gt;;
  final profile = await repo.update(
    userId: userId,
    bio: body['bio'] as String?,
    avatarUrl: body['avatarUrl'] as String?,
    phone: body['phone'] as String?,
    location: body['location'] as String?,
    website: body['website'] as String?,
  );
  if (profile == null) {
    return Response.json(statusCode: 404, body: {'error': 'Profile not found'});
  }
  return Response.json(body: profile.toJson());
}
</code></pre>
<h2 id="heading-wiring-the-middleware-pipeline">Wiring the Middleware Pipeline</h2>
<p>The routes and middleware are all written, but they aren't connected yet. In Dart Frog, the connection happens through <code>_middleware.dart</code> files placed strategically in the routes/ folder.</p>
<p>To review, a <code>_middleware.dart</code> file at the root level applies to every route in the project. A <code>_middleware.dart</code> inside a subfolder applies only to routes in that folder and below. This gives us precise, folder-scoped control over which middleware runs where without any manual registration or mounting.</p>
<p>Create <code>routes/_middleware.dart</code> for global middleware applied to every route:</p>
<pre><code class="language-dart">import 'package:dart_frog/dart_frog.dart';
import '../lib/middleware/database_middleware.dart';
import '../lib/middleware/error_middleware.dart';

Handler middleware(Handler handler) {
  return handler
      .use(databaseMiddleware())
      .use(errorMiddleware());
}
</code></pre>
<p>Create <code>routes/users/_middleware.dart</code> to protect all user routes with authentication:</p>
<pre><code class="language-dart">import 'package:dart_frog/dart_frog.dart';
import '../../lib/middleware/auth_middleware.dart';

Handler middleware(Handler handler) {
  return handler.use(authMiddleware());
}
</code></pre>
<p>This is one of the most elegant parts of Dart Frog's model. The routes/users/_middleware.dart file automatically applies auth to every route under routes/users/, including routes/users/index.dart, routes/users/[id].dart, and routes/users/[id]/profile.dart. The auth routes under routes/auth/ are untouched because they live outside the users/ folder.</p>
<p>There's no manual middleware mounting, no array of protected routes, and no route group configuration. The folder structure does the work.</p>
<h2 id="heading-testing-the-api">Testing the API</h2>
<p>With the server running and all routes wired up, we can verify the full flow end to end. Start the development server and run through each endpoint in order: register a user first to get a token, then use that token on the protected routes. Replace {userId} in the commands below with the actual ID returned from the register response.</p>
<p>Start the development server:</p>
<pre><code class="language-bash">dart_frog dev
# Server is now running at: http://localhost:8080
</code></pre>
<p>Register a user:</p>
<pre><code class="language-bash">curl http://localhost:8080/auth/register \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "email": "seyi@example.com",
    "password": "securepassword",
    "firstName": "Seyi",
    "lastName": "Dev"
  }'
</code></pre>
<p>Response:</p>
<pre><code class="language-json">{
  "user": {
    "id": "uuid-here",
    "email": "seyi@example.com",
    "firstName": "Seyi",
    "lastName": "Dev",
    "isActive": true,
    "createdAt": "2025-01-01T00:00:00.000Z",
    "updatedAt": "2025-01-01T00:00:00.000Z"
  },
  "token": "eyJhbGci..."
}
</code></pre>
<p>Login:</p>
<pre><code class="language-bash">curl http://localhost:8080/auth/login \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"email": "seyi@example.com", "password": "securepassword"}'
</code></pre>
<p>Get all users:</p>
<pre><code class="language-bash">curl http://localhost:8080/users \
  -H "Authorization: Bearer eyJhbGci..."
</code></pre>
<p>Get a specific user:</p>
<pre><code class="language-bash">curl http://localhost:8080/users/{userId} \
  -H "Authorization: Bearer eyJhbGci..."
</code></pre>
<p>Create a profile:</p>
<pre><code class="language-bash">curl http://localhost:8080/users/{userId}/profile \
  -X POST \
  -H "Authorization: Bearer eyJhbGci..." \
  -H "Content-Type: application/json" \
  -d '{
    "bio": "Flutter engineer turned backend developer",
    "location": "Lagos, Nigeria",
    "website": "https://example.com"
  }'
</code></pre>
<p>Update a user:</p>
<pre><code class="language-bash">curl http://localhost:8080/users/{userId} \
  -X PUT \
  -H "Authorization: Bearer eyJhbGci..." \
  -H "Content-Type: application/json" \
  -d '{"firstName": "Oluwaseyi"}'
</code></pre>
<p>Delete a user:</p>
<pre><code class="language-bash">curl http://localhost:8080/users/{userId} \
  -X DELETE \
  -H "Authorization: Bearer eyJhbGci..."
</code></pre>
<h2 id="heading-deployment">Deployment</h2>
<p>With everything tested locally, the final step is getting the API live. Dart Frog makes this straightforward: a single CLI command generates a production-ready Dockerfile, and from there we deploy to Fly.io where the app will run as a containerized service alongside a managed PostgreSQL database.</p>
<h3 id="heading-production-build">Production Build</h3>
<p>Dart Frog generates a production-ready Docker setup with a single command:</p>
<pre><code class="language-bash">dart_frog build
</code></pre>
<p>This creates a build/ directory containing:</p>
<pre><code class="language-plaintext">build/
  bin/
    server.dart         ← compiled entry point
  Dockerfile            ← production Dockerfile
  pubspec.yaml
  pubspec.lock
</code></pre>
<p>The generated Dockerfile is a multi-stage build, compiles to a native binary in the first stage, runs from a minimal Debian image in the second. You do not need to write this yourself.</p>
<h3 id="heading-deploying-to-flyio">Deploying to Fly.io</h3>
<p><strong>Step 1 — Authenticate:</strong></p>
<pre><code class="language-bash">fly auth login
</code></pre>
<p><strong>Step 2 — Launch from the build directory:</strong></p>
<pre><code class="language-bash">cd build
fly launch
</code></pre>
<p>Fly detects the Dockerfile and prompts for configuration. Create a PostgreSQL database when asked.</p>
<p><strong>Step 3 — Set secrets:</strong></p>
<pre><code class="language-bash">fly secrets set JWT_SECRET="your_production_jwt_secret"
fly secrets set JWT_EXPIRY_HOURS="24"
</code></pre>
<p><strong>Step 4 — Deploy:</strong></p>
<pre><code class="language-bash">fly deploy
</code></pre>
<p><strong>Step 5 — Verify:</strong></p>
<pre><code class="language-bash">curl https://your-app-name.fly.dev/auth/register \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password123","firstName":"Seyi","lastName":"Dev"}'
</code></pre>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Dart Frog sits exactly where it positions itself: between the raw control of Shelf and the full opinions of Serverpod. It takes the file-based routing model that has proven itself in the JavaScript ecosystem and brings it to Dart cleanly, without compromising on the language's strengths.</p>
<p>The routing model is its strongest feature. Looking at the routes/ folder tells you everything about your API: what endpoints exist, how they are grouped, and which middleware applies to which sections. That transparency makes codebases easier to navigate, easier to onboard into, and easier to reason about as they grow.</p>
<p>The RequestContext and the provider pattern for dependency injection are well thought out. Middleware injects, routes consume, and nothing bleeds between the two. The folder-scoped middleware is particularly clean, protecting an entire section of your API is as simple as dropping a _middleware.dart file in the right folder.</p>
<p>For Flutter engineers building APIs that need to serve multiple client types, conform to standard REST conventions, or integrate cleanly with existing frontend infrastructure, Dart Frog hits a practical sweet spot that neither Shelf nor Serverpod reaches as naturally.</p>
<p>Dart is now a full-stack language in the truest sense. The same team, the same language, the same conventions – from the Flutter app to the server that powers it.</p>
<p>Happy Coding!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ From Flutter to Backend: How to Build Production-Grade REST APIs with Dart and Serverpod ]]>
                </title>
                <description>
                    <![CDATA[ Serverpod is one of the most performant backend frameworks built on Dart. It's a fully opinionated backend framework that comes with its own ORM, its own code generation system, migration tooling, aut ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-production-grade-rest-apis-with-dart-and-serverpod/</link>
                <guid isPermaLink="false">6a1f040ecf96043972a543a7</guid>
                
                    <category>
                        <![CDATA[ Serverpod ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Server side rendering ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ backend ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Tue, 02 Jun 2026 16:25:50 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/910a29c7-b380-4432-bc3c-d2c6930c3ac9.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Serverpod is one of the most performant backend frameworks built on Dart. It's a fully opinionated backend framework that comes with its own ORM, its own code generation system, migration tooling, authentication module, and deployment platform.</p>
<p>If you use a tool like Shelf to build your API, you assemble everything yourself. You choose your packages, write your own middleware, manage your own database connection, and wire every piece together manually. That's the Shelf way, and it teaches you exactly how server-side Dart works under the hood.</p>
<p>Serverpod is a different philosophy entirely.</p>
<p>Where Shelf gives you primitives, Serverpod gives you a complete system. You define your models in YAML, run a generator, and get fully typed database classes, serialization, and client-side code produced automatically.</p>
<p>For Flutter engineers, this feels immediately familiar. It's the same kind of productivity you get from the Flutter toolchain itself, applied to the backend.</p>
<p>In this article, we're going to build a User and Profile Management REST API from scratch using Serverpod. You'll learn how Serverpod's code generation, built-in ORM, and endpoint system work, and you'll have a fully deployed backend by the end.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-serverpod-differs-from-shelf">How Serverpod Differs from Shelf</a></p>
</li>
<li><p><a href="#heading-installing-serverpod">Installing Serverpod</a></p>
</li>
<li><p><a href="#heading-creating-the-project">Creating the Project</a></p>
</li>
<li><p><a href="#heading-understanding-the-project-structure">Understanding the Project Structure</a></p>
</li>
<li><p><a href="#heading-serverpod-core-concepts">Serverpod Core Concepts</a></p>
<ul>
<li><p><a href="#heading-endpoints-and-the-session-object">Endpoints and the Session Object</a></p>
</li>
<li><p><a href="#heading-model-files-and-code-generation">Model Files and Code Generation</a></p>
</li>
<li><p><a href="#heading-the-built-in-orm">The Built-in ORM</a></p>
</li>
<li><p><a href="#heading-migrations">Migrations</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-starting-the-development-server">Starting the Development Server</a></p>
</li>
<li><p><a href="#heading-defining-the-models">Defining the Models</a></p>
<ul>
<li><p><a href="#heading-the-user-model">The User Model</a></p>
</li>
<li><p><a href="#heading-the-profile-model">The Profile Model</a></p>
</li>
<li><p><a href="#heading-running-code-generation">Running Code Generation</a></p>
</li>
<li><p><a href="#heading-creating-and-applying-migrations">Creating and Applying Migrations</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-building-the-api">Building the API</a></p>
<ul>
<li><p><a href="#heading-the-auth-endpoint">The Auth Endpoint</a></p>
</li>
<li><p><a href="#heading-the-user-endpoint">The User Endpoint</a></p>
</li>
<li><p><a href="#heading-the-profile-endpoint">The Profile Endpoint</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-authentication">Authentication</a></p>
<ul>
<li><p><a href="#heading-password-hashing-and-jwt">Password Hashing and JWT</a></p>
</li>
<li><p><a href="#heading-protecting-endpoints">Protecting Endpoints</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-error-handling-in-serverpod">Error Handling in Serverpod</a></p>
</li>
<li><p><a href="#heading-testing-the-api">Testing the API</a></p>
</li>
<li><p><a href="#heading-deployment">Deployment</a></p>
<ul>
<li><p><a href="#heading-deploying-with-docker-and-flyio">Deploying with Docker and Fly.io</a></p>
</li>
<li><p><a href="#heading-deploying-with-serverpod-cloud">Deploying with Serverpod Cloud</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before starting, you should have:</p>
<ul>
<li><p>Familiarity with Dart and Flutter development</p>
</li>
<li><p>Understanding of REST API concepts, endpoints, HTTP methods, status codes</p>
</li>
<li><p>Docker Desktop installed and running</p>
</li>
<li><p>Flutter SDK installed (Serverpod requires it even for server-only projects)</p>
</li>
<li><p>A Fly.io account or a Serverpod Cloud account for deployment</p>
</li>
</ul>
<h2 id="heading-how-serverpod-differs-from-shelf">How Serverpod Differs from Shelf</h2>
<p>Before writing a single line of code, it's worth understanding the fundamental difference in philosophy between Shelf and Serverpod. This will make every design decision in the framework feel deliberate rather than arbitrary.</p>
<p>With Shelf, you write everything. Request parsing, response formatting, database queries, migrations, auth, and logging. Every piece is explicit code that you understand because you wrote it.</p>
<p>With Serverpod, you define things and the framework writes code for you. You define a model in YAML, run serverpod generate, and get a full Dart class with database bindings, serialization, and client-side access automatically. You define an endpoint method, and the framework handles routing, parameter extraction, and response formatting.</p>
<p>This is the same trade-off Flutter makes compared to building with raw platform APIs. Flutter writes the layout engine, the rendering pipeline, and the gesture system for you. You focus on your product logic. Serverpod makes the same bet on the backend.</p>
<p>The cost of that productivity is flexibility. Serverpod has strong opinions about how things should be structured. If your use case fits those opinions, development is extremely fast. If it doesn't, you're working against the framework.</p>
<p>For the User and Profile Management API we're building here, Serverpod is a very good fit.</p>
<h2 id="heading-installing-serverpod">Installing Serverpod</h2>
<p>Serverpod requires Flutter to be installed, even for server-only work. This is because its toolchain builds client packages alongside the server package during project creation.</p>
<p>Install the Serverpod CLI globally:</p>
<pre><code class="language-bash">dart pub global activate serverpod_cli
</code></pre>
<p>Verify the installation:</p>
<pre><code class="language-bash">serverpod
# Should print the Serverpod CLI help
</code></pre>
<p>Make sure Docker Desktop is running before proceeding. Serverpod uses Docker to manage PostgreSQL and Redis for local development.</p>
<h2 id="heading-creating-the-project">Creating the Project</h2>
<pre><code class="language-bash">serverpod create user_profile_api
cd user_profile_api
</code></pre>
<p>This single command creates three Dart packages:</p>
<pre><code class="language-plaintext">user_profile_api/
  user_profile_api_server/    ← your server code
  user_profile_api_client/    ← auto-generated client (do not edit)
  user_profile_api_flutter/   ← Flutter app pre-configured to connect
</code></pre>
<p>For this article, everything we write lives in user_profile_api_server. The client and Flutter packages are generated automatically and used when you want a Flutter frontend talking to your Serverpod backend.</p>
<h2 id="heading-understanding-the-project-structure">Understanding the Project Structure</h2>
<p>Inside user_profile_api_server:</p>
<pre><code class="language-plaintext">user_profile_api_server/
  bin/
    main.dart                  ← entry point
  lib/
    src/
      endpoints/               ← your endpoint classes live here
      generated/               ← auto-generated code (never edit manually)
    user_profile_api_server.dart
  config/
    development.yaml           ← database and server config
    staging.yaml
    production.yaml
    passwords.yaml             ← database passwords
  migrations/                  ← auto-generated migration files
  web/                         ← optional web server files
  Dockerfile
  docker-compose.yaml
  pubspec.yaml
</code></pre>
<p>The most important thing to understand about this structure is the generated/ folder. Everything in there is produced by serverpod generate and should never be edited manually. When you change a model or endpoint, you run the generator and it rewrites that folder entirely.</p>
<p>The config/ folder holds environment-specific configuration. The development.yaml file is preconfigured to work with the Docker containers Serverpod spins up locally.</p>
<h2 id="heading-serverpod-core-concepts">Serverpod Core Concepts</h2>
<h3 id="heading-endpoints-and-the-session-object">Endpoints and the Session Object</h3>
<p>In Serverpod, an endpoint is a class that extends Endpoint. Every public method on that class becomes an API call that clients can make. There's no routing configuration, no handler registration, and no middleware mounting. The framework discovers and registers your endpoints automatically during code generation.</p>
<pre><code class="language-dart">import 'package:serverpod/serverpod.dart';

class UserEndpoint extends Endpoint {
  Future&lt;String&gt; greet(Session session, String name) async {
    return 'Hello, $name!';
  }
}
</code></pre>
<p>The Session object is the most important parameter in Serverpod. It's passed to every endpoint method and gives you access to:</p>
<ul>
<li><p>session.db for database operations</p>
</li>
<li><p>session.auth for authentication information</p>
</li>
<li><p>session.log for structured logging</p>
</li>
<li><p>session.caches for caching</p>
</li>
<li><p>session.messages for real-time messaging</p>
</li>
</ul>
<p>Think of Session as Serverpod's equivalent of Flutter's BuildContext. It's the gateway to everything the framework provides, and it's always the first parameter.</p>
<h3 id="heading-model-files-and-code-generation">Model Files and Code Generation</h3>
<p>This is where Serverpod's approach diverges most sharply from Shelf. Instead of writing Dart model classes manually, you define your data structures in .spy.yaml files and let Serverpod generate the Dart classes.</p>
<p>A model file for a Company looks like this:</p>
<pre><code class="language-yaml">class: Company
table: company
fields:
  name: String
  foundedDate: DateTime?
</code></pre>
<p>Running serverpod generate produces a full Dart class with:</p>
<ul>
<li><p>Immutable fields with correct types</p>
</li>
<li><p>toJson and fromJson for serialization</p>
</li>
<li><p>Database bindings through the db static accessor</p>
</li>
<li><p>Constructor and copyWith method</p>
</li>
<li><p>The same class is generated in the client package so the Flutter app can use it directly</p>
</li>
</ul>
<p>This is the core productivity gain. You define the shape once in YAML and get a consistent, typed model that works across the server, the database, and the client without duplication.</p>
<h3 id="heading-the-built-in-orm">The Built-in ORM</h3>
<p>Serverpod's ORM uses the generated model classes directly. All database operations go through the static db accessor on your model:</p>
<pre><code class="language-dart">// Insert a row
var company = Company(name: 'Serverpod Corp', foundedDate: DateTime.now());
company = await Company.db.insertRow(session, company);

// Find by ID
var found = await Company.db.findById(session, company.id!);

// Find with condition
var result = await Company.db.findFirstRow(
  session,
  where: (t) =&gt; t.name.equals('Serverpod Corp'),
);

// Find all with ordering
var all = await Company.db.find(
  session,
  orderBy: (t) =&gt; t.name,
);

// Update
company = company.copyWith(name: 'New Name');
await Company.db.updateRow(session, company);

// Delete
await Company.db.deleteRow(session, company);
</code></pre>
<p>The where parameter uses a type-safe expression builder. The t parameter gives you typed access to the table's columns, so you get autocompletion and compile-time checks on your query conditions. No raw SQL, no string-based column names, no runtime surprises.</p>
<h3 id="heading-migrations">Migrations</h3>
<p>When you change a model, Serverpod generates a migration automatically:</p>
<pre><code class="language-bash">serverpod create-migration
</code></pre>
<p>This creates a SQL migration file in the migrations/ directory. Apply it when starting the server:</p>
<pre><code class="language-bash">dart bin/main.dart --apply-migrations
</code></pre>
<p>Serverpod tracks which migrations have been applied and runs only the new ones. The migration system is fully integrated with the model system, so there's no drift between your Dart classes and your database schema.</p>
<h2 id="heading-starting-the-development-server">Starting the Development Server</h2>
<p>Before writing any code, get the development environment running.</p>
<p>Start the Docker containers (PostgreSQL and Redis):</p>
<pre><code class="language-bash">cd user_profile_api_server
docker compose up --build --detach
</code></pre>
<p>Start the server with migrations applied:</p>
<pre><code class="language-bash">dart bin/main.dart --apply-migrations
</code></pre>
<p>You should see:</p>
<pre><code class="language-plaintext">SERVERPOD version: 2.x.x, mode: development
Insights listening on port 8081
Server default listening on port 8080
Webserver listening on port 8082
</code></pre>
<p>Three ports: Port 8080 is the main API server. Port 8081 is the Serverpod Insights tool for monitoring. Port 8082 is an optional web server. For this article, we'll work exclusively with port 8080.</p>
<h2 id="heading-defining-the-models">Defining the Models</h2>
<h3 id="heading-the-user-model">The User Model</h3>
<p>Create lib/src/models/user.spy.yaml in the server package:</p>
<pre><code class="language-yaml">class: AppUser
table: app_users
fields:
  email: String
  passwordHash: String
  firstName: String
  lastName: String
  isActive: bool, default=true
indexes:
  app_users_email_idx:
    fields: email
    unique: true
</code></pre>
<p>A few things to note here. The class is named AppUser rather than User to avoid conflicts with Serverpod's internal User class from the auth module. The table key defines the PostgreSQL table name. The indexes block creates a unique index on the email column, enforcing uniqueness at the database level.</p>
<p>Serverpod automatically adds an id field of type int? to every model with a table key. You don't declare it yourself.</p>
<h3 id="heading-the-profile-model">The Profile Model</h3>
<p>Create lib/src/models/profile.spy.yaml:</p>
<pre><code class="language-yaml">class: Profile
table: profiles
fields:
  userId: int
  bio: String?
  avatarUrl: String?
  phone: String?
  location: String?
  website: String?
indexes:
  profiles_user_id_idx:
    fields: userId
    unique: true
</code></pre>
<p>userId is an int referencing the id of an AppUser. Serverpod's model system doesn't yet have a foreign key declaration syntax in the YAML, so referential integrity is handled at the application layer in the endpoint logic.</p>
<h3 id="heading-running-code-generation">Running Code Generation</h3>
<p>With both model files in place, run the generator:</p>
<pre><code class="language-bash">serverpod generate
</code></pre>
<p>This produces Dart classes in lib/src/generated/. For AppUser, you get:</p>
<pre><code class="language-dart">// This is auto-generated, never edit directly
class AppUser extends SerializableEntity {
  AppUser({
    this.id,
    required this.email,
    required this.passwordHash,
    required this.firstName,
    required this.lastName,
    this.isActive = true,
  });

  int? id;
  String email;
  String passwordHash;
  String firstName;
  String lastName;
  bool isActive;

  // db accessor for ORM operations
  static final db = AppUserRepository._();

  // Serialization methods
  factory AppUser.fromJson(Map&lt;String, dynamic&gt; jsonSerialization, ...) { ... }
  Map&lt;String, dynamic&gt; toJson() { ... }
}
</code></pre>
<p>The generated code is what your endpoints interact with. You never write this by hand.</p>
<h3 id="heading-creating-and-applying-migrations">Creating and Applying Migrations</h3>
<p>With the models generated, create the migration:</p>
<pre><code class="language-bash">serverpod create-migration
</code></pre>
<p>This creates timestamped SQL files in migrations/. Apply them:</p>
<pre><code class="language-bash">dart bin/main.dart --apply-migrations
</code></pre>
<p>Your app_users and profiles tables now exist in PostgreSQL with the correct columns and indexes.</p>
<h2 id="heading-building-the-api">Building the API</h2>
<h3 id="heading-the-auth-endpoint">The Auth Endpoint</h3>
<p>Create lib/src/endpoints/auth_endpoint.dart:</p>
<pre><code class="language-dart">import 'package:serverpod/serverpod.dart';
import 'package:bcrypt/bcrypt.dart';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import '../generated/protocol.dart';

class AuthEndpoint extends Endpoint {
  Future&lt;Map&lt;String, dynamic&gt;&gt; register(
    Session session,
    String email,
    String password,
    String firstName,
    String lastName,
  ) async {
    if (email.isEmpty || password.isEmpty || firstName.isEmpty || lastName.isEmpty) {
      throw Exception('All fields are required');
    }

    if (password.length &lt; 8) {
      throw Exception('Password must be at least 8 characters');
    }

    // Check for existing user
    final existing = await AppUser.db.findFirstRow(
      session,
      where: (t) =&gt; t.email.equals(email),
    );

    if (existing != null) {
      throw Exception('An account with this email already exists');
    }

    final passwordHash = BCrypt.hashpw(password, BCrypt.gensalt());

    var user = AppUser(
      email: email,
      passwordHash: passwordHash,
      firstName: firstName,
      lastName: lastName,
    );

    user = await AppUser.db.insertRow(session, user);

    final token = _generateToken(user);

    return {
      'user': _sanitizeUser(user),
      'token': token,
    };
  }

  Future&lt;Map&lt;String, dynamic&gt;&gt; login(
    Session session,
    String email,
    String password,
  ) async {
    if (email.isEmpty || password.isEmpty) {
      throw Exception('Email and password are required');
    }

    final user = await AppUser.db.findFirstRow(
      session,
      where: (t) =&gt; t.email.equals(email),
    );

    if (user == null || !BCrypt.checkpw(password, user.passwordHash)) {
      throw Exception('Invalid email or password');
    }

    if (!user.isActive) {
      throw Exception('This account has been deactivated');
    }

    final token = _generateToken(user);

    return {
      'user': _sanitizeUser(user),
      'token': token,
    };
  }

  String _generateToken(AppUser user) {
    final jwt = JWT({'sub': user.id, 'email': user.email});
    return jwt.sign(SecretKey(_jwtSecret), expiresIn: const Duration(hours: 24));
  }

  // Never return the password hash to the client
  Map&lt;String, dynamic&gt; _sanitizeUser(AppUser user) =&gt; {
        'id': user.id,
        'email': user.email,
        'firstName': user.firstName,
        'lastName': user.lastName,
        'isActive': user.isActive,
      };

  // Read from Serverpod's config system
  String get _jwtSecret =&gt;
      Session.serverpod.getPassword('jwtSecret') ?? 'fallback_dev_secret';
}
</code></pre>
<p>Serverpod endpoints return typed values. When you return a Map&lt;String, dynamic&gt;, Serverpod serializes it automatically. When you throw an Exception, Serverpod catches it and returns a structured error response to the client. No manual response formatting, no status code management for common cases.</p>
<h3 id="heading-the-user-endpoint">The User Endpoint</h3>
<p>Create lib/src/endpoints/user_endpoint.dart:</p>
<pre><code class="language-dart">import 'package:serverpod/serverpod.dart';
import '../generated/protocol.dart';

class UserEndpoint extends Endpoint {
  @override
  bool get requireLogin =&gt; true;

  Future&lt;List&lt;Map&lt;String, dynamic&gt;&gt;&gt; getAll(Session session) async {
    final users = await AppUser.db.find(
      session,
      where: (t) =&gt; t.isActive.equals(true),
      orderBy: (t) =&gt; t.id,
    );

    return users.map(_sanitizeUser).toList();
  }

  Future&lt;Map&lt;String, dynamic&gt;&gt; getById(Session session, int userId) async {
    final user = await AppUser.db.findById(session, userId);

    if (user == null || !user.isActive) {
      throw Exception('User not found');
    }

    return _sanitizeUser(user);
  }

  Future&lt;Map&lt;String, dynamic&gt;&gt; update(
    Session session,
    int userId,
    String? firstName,
    String? lastName,
  ) async {
    final user = await AppUser.db.findById(session, userId);

    if (user == null || !user.isActive) {
      throw Exception('User not found');
    }

    final updated = user.copyWith(
      firstName: firstName ?? user.firstName,
      lastName: lastName ?? user.lastName,
    );

    await AppUser.db.updateRow(session, updated);
    return _sanitizeUser(updated);
  }

  Future&lt;void&gt; delete(Session session, int userId) async {
    final user = await AppUser.db.findById(session, userId);

    if (user == null || !user.isActive) {
      throw Exception('User not found');
    }

    // Soft delete
    final deactivated = user.copyWith(isActive: false);
    await AppUser.db.updateRow(session, deactivated);
  }

  Map&lt;String, dynamic&gt; _sanitizeUser(AppUser user) =&gt; {
        'id': user.id,
        'email': user.email,
        'firstName': user.firstName,
        'lastName': user.lastName,
        'isActive': user.isActive,
      };
}
</code></pre>
<p>Notice @override bool get requireLogin =&gt; true. This is Serverpod's built-in mechanism for protecting endpoints. When this getter returns true, Serverpod validates the authentication token on every request to this endpoint before the method is called. Unauthenticated requests are rejected automatically by the framework.</p>
<h3 id="heading-the-profile-endpoint">The Profile Endpoint</h3>
<p>Create lib/src/endpoints/profile_endpoint.dart:</p>
<pre><code class="language-dart">import 'package:serverpod/serverpod.dart';
import '../generated/protocol.dart';

class ProfileEndpoint extends Endpoint {
  @override
  bool get requireLogin =&gt; true;

  Future&lt;Map&lt;String, dynamic&gt;&gt; getByUserId(
    Session session,
    int userId,
  ) async {
    final user = await AppUser.db.findById(session, userId);
    if (user == null || !user.isActive) {
      throw Exception('User not found');
    }

    final profile = await Profile.db.findFirstRow(
      session,
      where: (t) =&gt; t.userId.equals(userId),
    );

    if (profile == null) {
      throw Exception('Profile not found');
    }

    return _profileToMap(profile);
  }

  Future&lt;Map&lt;String, dynamic&gt;&gt; create(
    Session session,
    int userId,
    String? bio,
    String? avatarUrl,
    String? phone,
    String? location,
    String? website,
  ) async {
    final user = await AppUser.db.findById(session, userId);
    if (user == null || !user.isActive) {
      throw Exception('User not found');
    }

    final existing = await Profile.db.findFirstRow(
      session,
      where: (t) =&gt; t.userId.equals(userId),
    );

    if (existing != null) {
      throw Exception('Profile already exists for this user');
    }

    var profile = Profile(
      userId: userId,
      bio: bio,
      avatarUrl: avatarUrl,
      phone: phone,
      location: location,
      website: website,
    );

    profile = await Profile.db.insertRow(session, profile);
    return _profileToMap(profile);
  }

  Future&lt;Map&lt;String, dynamic&gt;&gt; update(
    Session session,
    int userId,
    String? bio,
    String? avatarUrl,
    String? phone,
    String? location,
    String? website,
  ) async {
    final profile = await Profile.db.findFirstRow(
      session,
      where: (t) =&gt; t.userId.equals(userId),
    );

    if (profile == null) {
      throw Exception('Profile not found');
    }

    final updated = profile.copyWith(
      bio: bio ?? profile.bio,
      avatarUrl: avatarUrl ?? profile.avatarUrl,
      phone: phone ?? profile.phone,
      location: location ?? profile.location,
      website: website ?? profile.website,
    );

    await Profile.db.updateRow(session, updated);
    return _profileToMap(updated);
  }

  Map&lt;String, dynamic&gt; _profileToMap(Profile profile) =&gt; {
        'id': profile.id,
        'userId': profile.userId,
        'bio': profile.bio,
        'avatarUrl': profile.avatarUrl,
        'phone': profile.phone,
        'location': profile.location,
        'website': profile.website,
      };
}
</code></pre>
<p>After adding these endpoints, run the generator again so Serverpod registers them:</p>
<pre><code class="language-bash">serverpod generate
</code></pre>
<h2 id="heading-authentication">Authentication</h2>
<h3 id="heading-password-hashing-and-jwt">Password Hashing and JWT</h3>
<p>Add the required packages to pubspec.yaml in the server package:</p>
<pre><code class="language-yaml">dependencies:
  serverpod: ^2.5.0
  bcrypt: ^1.1.3
  dart_jsonwebtoken: ^2.12.0
</code></pre>
<p>Then run dart pub get.</p>
<p>The _generateToken and _sanitizeUser helpers in the auth endpoint handle password hashing and JWT generation. The JWT secret is read from Serverpod's built-in password management system via Session.serverpod.getPassword('jwtSecret').</p>
<p>Add the secret to config/passwords.yaml:</p>
<pre><code class="language-yaml">development:
  database: 'dart_password'
  jwtSecret: 'your_development_jwt_secret_here'
</code></pre>
<p>This file is already in .gitignore by default in a Serverpod project. Production secrets are injected via environment variables or Serverpod Cloud's secret management.</p>
<h3 id="heading-protecting-endpoints">Protecting Endpoints</h3>
<p>Serverpod has two levels of endpoint protection:</p>
<p>requireLogin — rejects unauthenticated requests automatically:</p>
<pre><code class="language-dart">@override
bool get requireLogin =&gt; true;
</code></pre>
<p>requiredScopes — requires specific permission scopes:</p>
<pre><code class="language-dart">@override
Set&lt;Scope&gt; get requiredScopes =&gt; {Scope.admin};
</code></pre>
<p>For the User and Profile endpoints in this article, requireLogin is sufficient. The token from the login response is passed in the Authorization header on every subsequent request, and Serverpod validates it before the endpoint method is called.</p>
<p>Verifying the token inside an endpoint to get the current user's ID:</p>
<pre><code class="language-dart">Future&lt;void&gt; someProtectedMethod(Session session) async {
  final authInfo = await session.authenticated;

  if (authInfo == null) {
    throw Exception('Not authenticated');
  }

  final userId = authInfo.userId;
  // proceed with userId
}
</code></pre>
<h2 id="heading-error-handling-in-serverpod">Error Handling in Serverpod</h2>
<p>Serverpod handles exceptions thrown from endpoint methods and converts them into structured error responses automatically. When you throw:</p>
<pre><code class="language-dart">throw Exception('User not found');
</code></pre>
<p>The client receives a structured error response. For more granular control, Serverpod provides typed exceptions:</p>
<pre><code class="language-dart">throw ServerpodClientException('User not found', statusCode: 404);
</code></pre>
<p>For server-side logging without exposing details to the client:</p>
<pre><code class="language-dart">session.log('Unexpected error during user creation', level: LogLevel.error);
throw Exception('An internal error occurred');
</code></pre>
<p>Serverpod's logging system stores logs in the database and makes them queryable through the Insights dashboard on port 8081. Every request is automatically logged with timing information, endpoint name, and outcome, no additional middleware required.</p>
<h2 id="heading-testing-the-api">Testing the API</h2>
<p>Serverpod exposes its endpoints over HTTP. You can test them directly with curl, though the request format follows Serverpod's RPC convention rather than a traditional REST structure.</p>
<p>The generated URL pattern for an endpoint method is:</p>
<pre><code class="language-plaintext">POST /[endpoint]/[method]
</code></pre>
<p>With a JSON body containing the method parameters.</p>
<p>Register a user:</p>
<pre><code class="language-bash">curl http://localhost:8080/auth/register \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "email": "seyi@example.com",
    "password": "securepassword",
    "firstName": "Seyi",
    "lastName": "Dev"
  }'
</code></pre>
<p>Login:</p>
<pre><code class="language-bash">curl http://localhost:8080/auth/login \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"email": "seyi@example.com", "password": "securepassword"}'
</code></pre>
<p>Get all users (authenticated):</p>
<pre><code class="language-bash">curl http://localhost:8080/user/getAll \
  -X POST \
  -H "Authorization: Bearer eyJhbGci..."
</code></pre>
<p>Get user by ID:</p>
<pre><code class="language-bash">curl http://localhost:8080/user/getById \
  -X POST \
  -H "Authorization: Bearer eyJhbGci..." \
  -H "Content-Type: application/json" \
  -d '{"userId": 1}'
</code></pre>
<p>Create a profile:</p>
<pre><code class="language-bash">curl http://localhost:8080/profile/create \
  -X POST \
  -H "Authorization: Bearer eyJhbGci..." \
  -H "Content-Type: application/json" \
  -d '{
    "userId": 1,
    "bio": "Flutter engineer turned backend developer",
    "location": "Lagos, Nigeria",
    "website": "https://example.com"
  }'
</code></pre>
<p>Update a user:</p>
<pre><code class="language-bash">curl http://localhost:8080/user/update \
  -X POST \
  -H "Authorization: Bearer eyJhbGci..." \
  -H "Content-Type: application/json" \
  -d '{"userId": 1, "firstName": "Oluwaseyi"}'
</code></pre>
<p>Delete a user:</p>
<pre><code class="language-bash">curl http://localhost:8080/user/delete \
  -X POST \
  -H "Authorization: Bearer eyJhbGci..." \
  -H "Content-Type: application/json" \
  -d '{"userId": 1}'
</code></pre>
<h2 id="heading-deployment">Deployment</h2>
<h3 id="heading-deploying-with-docker-and-flyio">Deploying with Docker and Fly.io</h3>
<p>Serverpod generates a Dockerfile as part of the project creation. It's located in user_profile_api_server/Dockerfile and is ready to use.</p>
<p>The included docker-compose.yaml in the server package manages PostgreSQL and Redis for local development. For production deployment on Fly.io, the process follows the same Docker-based pattern covered in the deployment section below.</p>
<p><strong>Step 1 — Authenticate with Fly:</strong></p>
<pre><code class="language-bash">fly auth login
</code></pre>
<p><strong>Step 2 — Launch the app from the server directory:</strong></p>
<pre><code class="language-bash">cd user_profile_api_server
fly launch
</code></pre>
<p><strong>Step 3 — Set production secrets:</strong></p>
<pre><code class="language-bash">fly secrets set JWT_SECRET="your_production_jwt_secret"
</code></pre>
<p><strong>Step 4 — Update the production config:</strong></p>
<p>Edit config/production.yaml with your Fly-provisioned database connection details. Fly injects the DATABASE_URL environment variable which you map to the Serverpod config format.</p>
<p><strong>Step 5 — Deploy:</strong></p>
<pre><code class="language-bash">fly deploy
</code></pre>
<p><strong>Step 6 — Apply migrations on first deploy:</strong></p>
<pre><code class="language-bash">fly ssh console
dart bin/main.dart --apply-migrations --mode production
</code></pre>
<h3 id="heading-deploying-with-serverpod-cloud">Deploying with Serverpod Cloud</h3>
<p>Serverpod Cloud is the native deployment platform built specifically for Serverpod applications. It handles database provisioning, scaling, monitoring, and deployments with minimal configuration.</p>
<p>Install the Serverpod Cloud CLI:</p>
<pre><code class="language-bash">dart pub global activate serverpod_cloud_cli
</code></pre>
<p>Authenticate:</p>
<pre><code class="language-bash">scloud login
</code></pre>
<p>Create a project in the Serverpod Cloud dashboard at cloud.serverpod.dev, then link your local project:</p>
<pre><code class="language-bash">scloud link --project-id your-project-id
</code></pre>
<p>Deploy:</p>
<pre><code class="language-bash">scloud deploy
</code></pre>
<p>Serverpod Cloud provisions a managed PostgreSQL database, applies your migrations, and deploys your server automatically. It also provides the Insights dashboard for monitoring requests, logs, and performance in production.</p>
<p>For teams already committed to the Serverpod ecosystem, Serverpod Cloud is the fastest path to a production deployment.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Serverpod takes a fundamentally different approach from Shelf. Where Shelf gives you control, Serverpod gives you speed. You define your models in YAML, run a generator, and get database classes, serialization, and client code produced automatically. You write an endpoint method and the framework handles routing, parameter extraction, authentication, and error formatting.</p>
<p>The ORM is the strongest part of the experience. Type-safe query expressions, automatic migration generation, and no SQL drift between your code and your schema make database work noticeably faster and safer than raw SQL.</p>
<p>The cost is rigidity. Serverpod's URL structure, serialization format, and architectural conventions aren't optional. If your API needs to conform to a specific REST structure that differs from Serverpod's RPC style, you'll be working against the framework.</p>
<p>For greenfield Flutter backends where the Dart client will consume the API, Serverpod is hard to beat. The code sharing between server and client, the automatic client generation, and the tight toolchain integration make it the most productive Dart backend option available.</p>
<p>For APIs that need to serve multiple clients, conform to external REST conventions, or integrate with existing infrastructure that doesn't expect Serverpod's format, a lower-level tool like Shelf gives you more control. If you want to see how the same User and Profile Management API is built with Shelf and compare the two approaches directly, you can <a href="https://www.freecodecamp.org/news/how-to-build-and-ship-production-rest-apis-with-dart-and-shelf">find that article here</a>.</p>
<p>Knowing which tool fits which job is what separates a developer who knows a framework from one who understands backend development.</p>
<p>Happy coding!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ From Flutter to Backend: How to Build and Ship Production REST APIs with Dart and Shelf ]]>
                </title>
                <description>
                    <![CDATA[ As a Flutter engineer, you already know Dart. You understand async/await, you work with models and repositories, you think in clean architecture, and you have shipped real applications. The gap betwee ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-and-ship-production-rest-apis-with-dart-and-shelf/</link>
                <guid isPermaLink="false">6a1d92fa080b80f11f574194</guid>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ backend developments ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ APIs ]]>
                    </category>
                
                    <category>
                        <![CDATA[ REST API ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Mon, 01 Jun 2026 14:11:06 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8ba5ec9d-22ba-4313-9b34-ce1e0e7dce23.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>As a Flutter engineer, you already know Dart. You understand async/await, you work with models and repositories, you think in clean architecture, and you have shipped real applications.</p>
<p>The gap between where you are and being able to build and deploy a production backend is smaller than you think.</p>
<p>The missing piece is not a new language. It's not a new paradigm. It's understanding how Dart behaves when there's no widget tree, no BuildContext, no Flutter framework – just a running process handling HTTP requests, talking to a database, and sending responses back to clients.</p>
<p>That's exactly what this article covers.</p>
<p>We're going to build a full User and Profile Management REST API from scratch using Dart and Shelf, connect it to a PostgreSQL database running in Docker, secure it with JWT authentication, and deploy it to Fly.io.</p>
<p>By the end, you'll have a working production-grade backend written entirely in Dart, the same language you already know.</p>
<p>This article is part of a series (of standalone articles) where we'll build the same project using three different frameworks. We'll use Shelf here, Serverpod in the next article, and Dart Frog in the one after that. This will let you directly compare how each framework approaches the same problem.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-how-dart-works-on-the-server">How Dart Works on the Server</a></p>
</li>
<li><p><a href="#heading-what-is-shelf">What is Shelf?</a></p>
</li>
<li><p><a href="#heading-project-setup">Project Setup</a></p>
<ul>
<li><p><a href="#heading-creating-the-project">Creating the Project</a></p>
</li>
<li><p><a href="#heading-project-structure">Project Structure</a></p>
</li>
<li><p><a href="#heading-database-setup-with-docker">Database Setup with Docker</a></p>
</li>
<li><p><a href="#heading-environment-configuration">Environment Configuration</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-shelf-core-concepts">Shelf Core Concepts</a></p>
<ul>
<li><p><a href="#heading-handlers">Handlers</a></p>
</li>
<li><p><a href="#heading-request-and-response">Request and Response</a></p>
</li>
<li><p><a href="#heading-router">Router</a></p>
</li>
<li><p><a href="#heading-pipeline-and-middleware">Pipeline and Middleware</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-connecting-to-postgresql">Connecting to PostgreSQL</a></p>
<ul>
<li><p><a href="#heading-the-database-connection-manager">The Database Connection Manager</a></p>
</li>
<li><p><a href="#heading-running-migrations">Running Migrations</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-building-the-api">Building the API</a></p>
<ul>
<li><p><a href="#heading-the-user-model">The User Model</a></p>
</li>
<li><p><a href="#heading-the-user-repository">The User Repository</a></p>
</li>
<li><p><a href="#heading-user-handlers">User Handlers</a></p>
</li>
<li><p><a href="#heading-the-profile-model">The Profile Model</a></p>
</li>
<li><p><a href="#heading-the-profile-repository">The Profile Repository</a></p>
</li>
<li><p><a href="#heading-profile-handlers">Profile Handlers</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-authentication">Authentication</a></p>
<ul>
<li><p><a href="#heading-password-hashing">Password Hashing</a></p>
</li>
<li><p><a href="#heading-jwt-token-generation-and-validation">JWT Token Generation and Validation</a></p>
</li>
<li><p><a href="#heading-auth-handlers">Auth Handlers</a></p>
</li>
<li><p><a href="#heading-auth-middleware">Auth Middleware</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-error-handling">Error Handling</a></p>
</li>
<li><p><a href="#heading-wiring-everything-together">Wiring Everything Together</a></p>
</li>
<li><p><a href="#heading-deployment">Deployment</a></p>
<ul>
<li><p><a href="#heading-dockerfile">Dockerfile</a></p>
</li>
<li><p><a href="#heading-docker-compose-for-local-production-testing">Docker Compose for Local Production Testing</a></p>
</li>
<li><p><a href="#heading-deploying-to-flyio">Deploying to Fly.io</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-testing-the-api">Testing the API</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before starting, you should have:</p>
<ul>
<li><p>Comfortable familiarity with Dart and Flutter development</p>
</li>
<li><p>Understanding of REST API concepts, endpoints, HTTP methods, status codes</p>
</li>
<li><p>Docker Desktop installed and running</p>
</li>
<li><p>A Fly.io account (free tier is sufficient, fly.io)</p>
</li>
<li><p>The Fly CLI installed (brew install flyctl on macOS, or the official installer on Windows/Linux)</p>
</li>
<li><p>A PostgreSQL client for inspecting the database, like TablePlus or DBeaver – both work well</p>
</li>
</ul>
<h2 id="heading-how-dart-works-on-the-server">How Dart Works on the Server</h2>
<p>When you run a Flutter app, the Flutter framework is doing an enormous amount of work, managing the widget tree, handling the render pipeline, coordinating state, and responding to platform events. Your Dart code sits on top of all of that.</p>
<p>On the server, none of that exists. There's no widget tree. There's no framework managing a UI lifecycle. There's just a Dart process running, listening on a port, receiving HTTP requests, doing work, and sending responses.</p>
<p>Dart's standard library, dart:io, has everything needed to do this at the lowest level:</p>
<pre><code class="language-dart">import 'dart:io';

void main() async {
  final server = await HttpServer.bind('0.0.0.0', 8080);
  print('Server running on port 8080');

  await for (final request in server) {
    request.response
      ..statusCode = 200
      ..write('Hello from Dart')
      ..close();
  }
}
</code></pre>
<p>This is a working HTTP server in raw Dart. No packages, no framework. Every request comes in through the HttpServer stream, and you write directly to the response.</p>
<p>This works, but it scales poorly. As soon as you need routing, middleware, authentication, and structured error handling, raw dart:io becomes difficult to manage. That is the problem Shelf solves.</p>
<h2 id="heading-what-is-shelf">What is Shelf?</h2>
<p>Shelf is a composable web server middleware library for Dart, maintained by the Dart team. It doesn't try to be a full framework – instead, it gives you the primitives to build one, or to assemble exactly what you need.</p>
<p>The Shelf mental model is built on four concepts:</p>
<ul>
<li><p><strong>Handler:</strong> a function that takes a Request and returns a Response. Everything in Shelf is ultimately a handler.</p>
</li>
<li><p><strong>Middleware:</strong> a function that wraps a handler, adding behaviour before or after it runs. Logging, authentication, and error handling are all middleware.</p>
</li>
<li><p><strong>Pipeline:</strong> a chain of middleware with a handler at the end. Requests flow through the middleware chain before reaching the handler.</p>
</li>
<li><p><strong>Router:</strong> maps URL patterns and HTTP methods to specific handlers.</p>
</li>
</ul>
<p>If you've used Flutter's Navigator or provider middleware concepts, the composition model will feel familiar. Small, single-responsibility pieces assembled into a working whole.</p>
<h2 id="heading-project-setup">Project Setup</h2>
<h3 id="heading-creating-the-project">Creating the Project</h3>
<p>Dart includes a server-side project template that gives us a clean starting point:</p>
<pre><code class="language-bash">dart create -t server-shelf user_profile_api
cd user_profile_api
</code></pre>
<p>Add the dependencies we need to pubspec.yaml:</p>
<pre><code class="language-yaml">name: user_profile_api
description: User and Profile Management REST API built with Dart and Shelf
version: 1.0.0

environment:
  sdk: '&gt;=3.0.0 &lt;4.0.0'

dependencies:
  shelf: ^1.4.1
  shelf_router: ^1.1.4
  postgres: ^3.3.0
  dart_jsonwebtoken: ^2.12.0
  bcrypt: ^1.1.3
  dotenv: ^4.1.0
  crypto: ^3.0.3

dev_dependencies:
  lints: ^3.0.0
  test: ^1.24.0
</code></pre>
<p>Run:</p>
<pre><code class="language-bash">dart pub get
</code></pre>
<h3 id="heading-project-structure">Project Structure</h3>
<p>Now we'll build a backend project structure that Flutter engineers will find intuitive, that's familiar enough to navigate immediately, and that's correct enough for backend conventions:</p>
<pre><code class="language-plaintext">user_profile_api/
  bin/
    server.dart              ← entry point
  lib/
    config/
      database.dart          ← connection manager
      env.dart               ← environment config
    handlers/
      auth_handler.dart      ← auth endpoints
      user_handler.dart      ← user endpoints
      profile_handler.dart   ← profile endpoints
    middleware/
      auth_middleware.dart   ← JWT validation
      error_middleware.dart  ← global error handling
      logger_middleware.dart ← request logging
    models/
      user.dart
      profile.dart
    repositories/
      user_repository.dart
      profile_repository.dart
    services/
      auth_service.dart      ← JWT + password logic
    router.dart              ← route definitions
  migrations/
    001_create_users.sql
    002_create_profiles.sql
  docker-compose.yml
  Dockerfile
  .env
  .env.example
</code></pre>
<p>This separation of concerns maps directly to what you'll already know if you're a Flutter engineer: models, repositories, and services are the same concepts. Handlers replace ViewModels or Controllers. Middleware replaces interceptors.</p>
<h3 id="heading-database-setup-with-docker">Database Setup with Docker</h3>
<p>Create docker-compose.yml in the project root:</p>
<pre><code class="language-yaml">version: '3.8'

services:
  postgres:
    image: postgres:16-alpine
    container_name: user_profile_db
    environment:
      POSTGRES_DB: user_profile_api
      POSTGRES_USER: dart_user
      POSTGRES_PASSWORD: dart_password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data

volumes:
  postgres_data:
</code></pre>
<p>Start the database:</p>
<pre><code class="language-bash">docker compose up -d
</code></pre>
<p>Verify that it's running:</p>
<pre><code class="language-bash">docker compose ps
# user_profile_db   running   0.0.0.0:5432-&gt;5432/tcp
</code></pre>
<h3 id="heading-environment-configuration">Environment Configuration</h3>
<p>Create .env in the project root:</p>
<pre><code class="language-plaintext">DB_HOST=localhost
DB_PORT=5432
DB_NAME=user_profile_api
DB_USER=dart_user
DB_PASSWORD=dart_password
JWT_SECRET=your_super_secret_key_change_this_in_production
JWT_EXPIRY_HOURS=24
PORT=8080
</code></pre>
<p>Create .env.example with the same keys but no values. This is what you commit to Git:</p>
<pre><code class="language-plaintext">DB_HOST=
DB_PORT=
DB_NAME=
DB_USER=
DB_PASSWORD=
JWT_SECRET=
JWT_EXPIRY_HOURS=
PORT=
</code></pre>
<p>Add .env to .gitignore:</p>
<pre><code class="language-plaintext">.env
</code></pre>
<p>Create lib/config/env.dart:</p>
<pre><code class="language-dart">import 'package:dotenv/dotenv.dart';

class Env {
  static late final DotEnv _env;

  static void load() {
    _env = DotEnv(includePlatformEnvironment: true)..load();
  }

  static String get dbHost =&gt; _env['DB_HOST'] ?? 'localhost';
  static int get dbPort =&gt; int.parse(_env['DB_PORT'] ?? '5432');
  static String get dbName =&gt; _env['DB_NAME'] ?? 'user_profile_api';
  static String get dbUser =&gt; _env['DB_USER'] ?? 'dart_user';
  static String get dbPassword =&gt; _env['DB_PASSWORD'] ?? '';
  static String get jwtSecret =&gt; _env['JWT_SECRET'] ?? '';
  static int get jwtExpiryHours =&gt; int.parse(_env['JWT_EXPIRY_HOURS'] ?? '24');
  static int get port =&gt; int.parse(_env['PORT'] ?? '8080');
}
</code></pre>
<p>includePlatformEnvironment: true means the Env class reads from both the .env file and real system environment variables, so the same code works locally with a .env file and in production with injected environment variables.</p>
<h2 id="heading-shelf-core-concepts">Shelf Core Concepts</h2>
<p>Before building the API, it's worth understanding each Shelf concept properly – not just what it does, but why it's designed the way it is.</p>
<h3 id="heading-handlers">Handlers</h3>
<p>A handler is the most fundamental unit in Shelf. It's simply a function:</p>
<pre><code class="language-dart">import 'package:shelf/shelf.dart';

Response helloHandler(Request request) {
  return Response.ok('Hello, Dart backend!');
}
</code></pre>
<p>Request in, Response out. That's the entire contract. Every endpoint you write is a handler. Every piece of middleware is a function that takes a handler and returns a handler.</p>
<p>Handlers can be async:</p>
<pre><code class="language-dart">Future&lt;Response&gt; getUserHandler(Request request) async {
  final users = await userRepository.findAll();
  return Response.ok(jsonEncode(users));
}
</code></pre>
<h3 id="heading-request-and-response">Request and Response</h3>
<p>Request gives you everything about the incoming HTTP call:</p>
<pre><code class="language-dart">Future&lt;Response&gt; handler(Request request) async {
  // URL and path
  print(request.url);           // the full URL
  print(request.url.path);      // just the path

  // Path parameters (when using shelf_router)
  final id = request.params['id'];

  // Query parameters
  final page = request.url.queryParameters['page'];

  // Headers
  final auth = request.headers['authorization'];

  // Body
  final body = await request.readAsString();
  final json = jsonDecode(body) as Map&lt;String, dynamic&gt;;

  return Response.ok('handled');
}
</code></pre>
<p>Response has named constructors for common status codes:</p>
<pre><code class="language-dart">Response.ok(body)           // 200
Response.notFound(body)     // 404
Response(201, body: body)   // any status code
Response(400, body: body)   // bad request
Response(401, body: body)   // unauthorized
Response(500, body: body)   // server error
</code></pre>
<p>Always set the Content-Type header when returning JSON:</p>
<pre><code class="language-dart">Response.ok(
  jsonEncode({'message': 'success'}),
  headers: {'Content-Type': 'application/json'},
)
</code></pre>
<h3 id="heading-router">Router</h3>
<p>shelf_router maps URL patterns and HTTP methods to handlers:</p>
<pre><code class="language-dart">import 'package:shelf_router/shelf_router.dart';

final router = Router();

router.get('/users', getAllUsersHandler);
router.get('/users/&lt;id&gt;', getUserHandler);
router.post('/users', createUserHandler);
router.put('/users/&lt;id&gt;', updateUserHandler);
router.delete('/users/&lt;id&gt;', deleteUserHandler);
</code></pre>
<p>The syntax defines a path parameter. Access it inside the handler via request.params['id'].</p>
<h3 id="heading-pipeline-and-middleware">Pipeline and Middleware</h3>
<p>A Pipeline chains middleware together with a handler at the end:</p>
<pre><code class="language-dart">import 'package:shelf/shelf.dart';

final handler = Pipeline()
    .addMiddleware(loggerMiddleware())
    .addMiddleware(errorMiddleware())
    .addMiddleware(authMiddleware())
    .addHandler(router.call);
</code></pre>
<p>Middleware is a function with this signature:</p>
<pre><code class="language-dart">Middleware myMiddleware() {
  return (Handler innerHandler) {
    return (Request request) async {
      // Before the handler runs
      print('Request received: \({request.method} \){request.url}');

      final response = await innerHandler(request);

      // After the handler runs
      print('Response sent: ${response.statusCode}');

      return response;
    };
  };
}
</code></pre>
<p>The outer function returns a Middleware. That Middleware is a function that takes the next Handler in the chain and returns a new Handler. This nesting is what allows middleware to run code both before and after the inner handler.</p>
<h2 id="heading-connecting-to-postgresql">Connecting to PostgreSQL</h2>
<h3 id="heading-the-database-connection-manager">The Database Connection Manager</h3>
<p>Create lib/config/database.dart:</p>
<pre><code class="language-dart">import 'package:postgres/postgres.dart';
import 'env.dart';

class Database {
  static Connection? _connection;

  static Future&lt;Connection&gt; get connection async {
    if (_connection != null) return _connection!;
    _connection = await _connect();
    return _connection!;
  }

  static Future&lt;Connection&gt; _connect() async {
    final conn = await Connection.open(
      Endpoint(
        host: Env.dbHost,
        port: Env.dbPort,
        database: Env.dbName,
        username: Env.dbUser,
        password: Env.dbPassword,
      ),
      settings: const ConnectionSettings(
        sslMode: SslMode.disable,
      ),
    );

    print('✅ Database connected: \({Env.dbHost}:\){Env.dbPort}/${Env.dbName}');
    return conn;
  }

  static Future&lt;void&gt; close() async {
    await _connection?.close();
    _connection = null;
  }
}
</code></pre>
<p>This is a singleton connection manager – the same pattern Flutter engineers use for shared services. The connection is created once on first access and reused for every subsequent database call.</p>
<h3 id="heading-running-migrations">Running Migrations</h3>
<p>Create the migrations folder and SQL files:</p>
<p>migrations/001_create_users.sql:</p>
<pre><code class="language-sql">CREATE TABLE IF NOT EXISTS users (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email VARCHAR(255) UNIQUE NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  first_name VARCHAR(100) NOT NULL,
  last_name VARCHAR(100) NOT NULL,
  is_active BOOLEAN DEFAULT TRUE,
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
</code></pre>
<p>migrations/002_create_profiles.sql:</p>
<pre><code class="language-sql">CREATE TABLE IF NOT EXISTS profiles (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  bio TEXT,
  avatar_url VARCHAR(500),
  phone VARCHAR(20),
  location VARCHAR(255),
  website VARCHAR(500),
  created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
  UNIQUE(user_id)
);

CREATE INDEX IF NOT EXISTS idx_profiles_user_id ON profiles(user_id);
</code></pre>
<p>Create a migration runner in lib/config/database.dart:</p>
<pre><code class="language-dart">static Future&lt;void&gt; runMigrations() async {
  final conn = await connection;
  final migrationsDir = Directory('migrations');

  final files = migrationsDir
      .listSync()
      .whereType&lt;File&gt;()
      .where((f) =&gt; f.path.endsWith('.sql'))
      .toList()
    ..sort((a, b) =&gt; a.path.compareTo(b.path));

  for (final file in files) {
    final sql = await file.readAsString();
    await conn.execute(sql);
    print('✅ Migration applied: ${file.path}');
  }
}
</code></pre>
<h2 id="heading-building-the-api">Building the API</h2>
<p>With the database connected and migrations in place, we can now build the actual API layer.</p>
<p>This section covers the models, repositories, and handlers for both users and profiles. Models define the shape of the data, repositories handle all database interactions, and handlers translate HTTP requests into repository calls and send responses back to the client. We'll build the user layer first, then the profile layer on top of it.</p>
<h3 id="heading-the-user-model">The User Model</h3>
<p>The User model represents a single user record in the database. It maps directly to the users table created in the migration and handles two-way conversion between database rows and Dart objects.</p>
<p>Create lib/models/user.dart:</p>
<pre><code class="language-dart">class User {
  final String id;
  final String email;
  final String passwordHash;
  final String firstName;
  final String lastName;
  final bool isActive;
  final DateTime createdAt;
  final DateTime updatedAt;

  const User({
    required this.id,
    required this.email,
    required this.passwordHash,
    required this.firstName,
    required this.lastName,
    required this.isActive,
    required this.createdAt,
    required this.updatedAt,
  });

  factory User.fromRow(Map&lt;String, dynamic&gt; row) =&gt; User(
        id: row['id'] as String,
        email: row['email'] as String,
        passwordHash: row['password_hash'] as String,
        firstName: row['first_name'] as String,
        lastName: row['last_name'] as String,
        isActive: row['is_active'] as bool,
        createdAt: row['created_at'] as DateTime,
        updatedAt: row['updated_at'] as DateTime,
      );

  // Never include passwordHash in JSON responses
  Map&lt;String, dynamic&gt; toJson() =&gt; {
        'id': id,
        'email': email,
        'firstName': firstName,
        'lastName': lastName,
        'isActive': isActive,
        'createdAt': createdAt.toIso8601String(),
        'updatedAt': updatedAt.toIso8601String(),
      };
}
</code></pre>
<p>fromRow maps a PostgreSQL result row to a User. toJson deliberately excludes passwordHash – you should never return password data in API responses.</p>
<h3 id="heading-the-user-repository">The User Repository</h3>
<p>The UserRepository is the single point of contact between the application and the users table. Every database operation for users goes through here, keeping the SQL contained and the handlers clean.</p>
<p>Create lib/repositories/user_repository.dart:</p>
<pre><code class="language-dart">import 'dart:async';
import 'package:postgres/postgres.dart';
import '../config/database.dart';
import '../models/user.dart';

class UserRepository {
  Future&lt;Connection&gt; get _conn =&gt; Database.connection;

  Future&lt;List&lt;User&gt;&gt; findAll() async {
    final conn = await _conn;
    final results = await conn.execute(
      'SELECT * FROM users WHERE is_active = TRUE ORDER BY created_at DESC',
    );

    return results.map((row) =&gt; User.fromRow(row.toColumnMap())).toList();
  }

  Future&lt;User?&gt; findById(String id) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('SELECT * FROM users WHERE id = @id AND is_active = TRUE'),
      parameters: {'id': id},
    );

    if (results.isEmpty) return null;
    return User.fromRow(results.first.toColumnMap());
  }

  Future&lt;User?&gt; findByEmail(String email) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('SELECT * FROM users WHERE email = @email'),
      parameters: {'email': email},
    );

    if (results.isEmpty) return null;
    return User.fromRow(results.first.toColumnMap());
  }

  Future&lt;User&gt; create({
    required String email,
    required String passwordHash,
    required String firstName,
    required String lastName,
  }) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('''
        INSERT INTO users (email, password_hash, first_name, last_name)
        VALUES (@email, @passwordHash, @firstName, @lastName)
        RETURNING *
      '''),
      parameters: {
        'email': email,
        'passwordHash': passwordHash,
        'firstName': firstName,
        'lastName': lastName,
      },
    );

    return User.fromRow(results.first.toColumnMap());
  }

  Future&lt;User?&gt; update({
    required String id,
    String? firstName,
    String? lastName,
  }) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('''
        UPDATE users
        SET
          first_name = COALESCE(@firstName, first_name),
          last_name  = COALESCE(@lastName, last_name),
          updated_at = NOW()
        WHERE id = @id AND is_active = TRUE
        RETURNING *
      '''),
      parameters: {
        'id': id,
        'firstName': firstName,
        'lastName': lastName,
      },
    );

    if (results.isEmpty) return null;
    return User.fromRow(results.first.toColumnMap());
  }

  Future&lt;bool&gt; delete(String id) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('''
        UPDATE users SET is_active = FALSE, updated_at = NOW()
        WHERE id = @id AND is_active = TRUE
        RETURNING id
      '''),
      parameters: {'id': id},
    );

    return results.isNotEmpty;
  }
}
</code></pre>
<p>A few things worth noting here. Sql.named uses named parameters (@paramName) instead of positional parameters. This prevents SQL injection and makes queries readable.</p>
<p>Also, the delete operation is a soft delete. It sets is_active = FALSE rather than removing the row. This is the standard production approach: data is never truly deleted, it's deactivated.</p>
<p>COALESCE(@firstName, first_name) on the update means: use the new value if provided, otherwise keep the existing value. This handles partial updates cleanly without requiring all fields every time.</p>
<h3 id="heading-user-handlers">User Handlers</h3>
<p>The UserHandler class exposes the repository operations as HTTP endpoints. It owns a Router instance internally and maps each route to a private method, keeping the routing logic and the handler logic together in one place.</p>
<p>Create lib/handlers/user_handler.dart:</p>
<pre><code class="language-dart">import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../repositories/user_repository.dart';

class UserHandler {
  final UserRepository _repository;

  UserHandler(this._repository);

  Router get router {
    final router = Router();
    router.get('/', _getAll);
    router.get('/&lt;id&gt;', _getOne);
    router.put('/&lt;id&gt;', _update);
    router.delete('/&lt;id&gt;', _delete);
    return router;
  }

  Future&lt;Response&gt; _getAll(Request request) async {
    final users = await _repository.findAll();
    return Response.ok(
      jsonEncode(users.map((u) =&gt; u.toJson()).toList()),
      headers: {'Content-Type': 'application/json'},
    );
  }

  Future&lt;Response&gt; _getOne(Request request, String id) async {
    final user = await _repository.findById(id);

    if (user == null) {
      return Response.notFound(
        jsonEncode({'error': 'User not found'}),
        headers: {'Content-Type': 'application/json'},
      );
    }

    return Response.ok(
      jsonEncode(user.toJson()),
      headers: {'Content-Type': 'application/json'},
    );
  }

  Future&lt;Response&gt; _update(Request request, String id) async {
    final body = jsonDecode(await request.readAsString()) as Map&lt;String, dynamic&gt;;

    final user = await _repository.update(
      id: id,
      firstName: body['firstName'] as String?,
      lastName: body['lastName'] as String?,
    );

    if (user == null) {
      return Response.notFound(
        jsonEncode({'error': 'User not found'}),
        headers: {'Content-Type': 'application/json'},
      );
    }

    return Response.ok(
      jsonEncode(user.toJson()),
      headers: {'Content-Type': 'application/json'},
    );
  }

  Future&lt;Response&gt; _delete(Request request, String id) async {
    final deleted = await _repository.delete(id);

    if (!deleted) {
      return Response.notFound(
        jsonEncode({'error': 'User not found'}),
        headers: {'Content-Type': 'application/json'},
      );
    }

    return Response(
      204,
      headers: {'Content-Type': 'application/json'},
    );
  }
}
</code></pre>
<h3 id="heading-the-profile-model">The Profile Model</h3>
<p>The Profile model represents a user's extended information, stored separately from the core user record. The one-to-one relationship is enforced by the unique index on user_id in the profiles table. All fields except userId are nullable since a profile can be created with partial information and filled in over time.</p>
<p>Create lib/models/profile.dart:</p>
<pre><code class="language-dart">class Profile {
  final String id;
  final String userId;
  final String? bio;
  final String? avatarUrl;
  final String? phone;
  final String? location;
  final String? website;
  final DateTime createdAt;
  final DateTime updatedAt;

  const Profile({
    required this.id,
    required this.userId,
    this.bio,
    this.avatarUrl,
    this.phone,
    this.location,
    this.website,
    required this.createdAt,
    required this.updatedAt,
  });

  factory Profile.fromRow(Map&lt;String, dynamic&gt; row) =&gt; Profile(
        id: row['id'] as String,
        userId: row['user_id'] as String,
        bio: row['bio'] as String?,
        avatarUrl: row['avatar_url'] as String?,
        phone: row['phone'] as String?,
        location: row['location'] as String?,
        website: row['website'] as String?,
        createdAt: row['created_at'] as DateTime,
        updatedAt: row['updated_at'] as DateTime,
      );

  Map&lt;String, dynamic&gt; toJson() =&gt; {
        'id': id,
        'userId': userId,
        'bio': bio,
        'avatarUrl': avatarUrl,
        'phone': phone,
        'location': location,
        'website': website,
        'createdAt': createdAt.toIso8601String(),
        'updatedAt': updatedAt.toIso8601String(),
      };
}
</code></pre>
<h3 id="heading-the-profile-repository">The Profile Repository</h3>
<p>The ProfileRepository handles all database operations for the profiles table. Unlike the user repository which looks up by id, most profile operations use userId as the lookup key since that is how the client references a profile — by whose it belongs to, not by its own internal ID.</p>
<p>Create lib/repositories/profile_repository.dart:</p>
<pre><code class="language-dart">import 'package:postgres/postgres.dart';
import '../config/database.dart';
import '../models/profile.dart';

class ProfileRepository {
  Future&lt;Connection&gt; get _conn =&gt; Database.connection;

  Future&lt;Profile?&gt; findByUserId(String userId) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('SELECT * FROM profiles WHERE user_id = @userId'),
      parameters: {'userId': userId},
    );

    if (results.isEmpty) return null;
    return Profile.fromRow(results.first.toColumnMap());
  }

  Future&lt;Profile&gt; create({
    required String userId,
    String? bio,
    String? avatarUrl,
    String? phone,
    String? location,
    String? website,
  }) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('''
        INSERT INTO profiles (user_id, bio, avatar_url, phone, location, website)
        VALUES (@userId, @bio, @avatarUrl, @phone, @location, @website)
        RETURNING *
      '''),
      parameters: {
        'userId': userId,
        'bio': bio,
        'avatarUrl': avatarUrl,
        'phone': phone,
        'location': location,
        'website': website,
      },
    );

    return Profile.fromRow(results.first.toColumnMap());
  }

  Future&lt;Profile?&gt; update({
    required String userId,
    String? bio,
    String? avatarUrl,
    String? phone,
    String? location,
    String? website,
  }) async {
    final conn = await _conn;
    final results = await conn.execute(
      Sql.named('''
        UPDATE profiles
        SET
          bio        = COALESCE(@bio, bio),
          avatar_url = COALESCE(@avatarUrl, avatar_url),
          phone      = COALESCE(@phone, phone),
          location   = COALESCE(@location, location),
          website    = COALESCE(@website, website),
          updated_at = NOW()
        WHERE user_id = @userId
        RETURNING *
      '''),
      parameters: {
        'userId': userId,
        'bio': bio,
        'avatarUrl': avatarUrl,
        'phone': phone,
        'location': location,
        'website': website,
      },
    );

    if (results.isEmpty) return null;
    return Profile.fromRow(results.first.toColumnMap());
  }
}
</code></pre>
<h3 id="heading-profile-handlers">Profile Handlers</h3>
<p>The ProfileHandler manages the profile endpoints nested under a user's ID. Before every operation, it verifies the parent user exists — a profile can't be created, fetched, or updated for a user that doesn't exist. It also prevents duplicate profiles by checking for an existing record before allowing a create.</p>
<p>Create lib/handlers/profile_handler.dart:</p>
<pre><code class="language-dart">import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../repositories/profile_repository.dart';
import '../repositories/user_repository.dart';

class ProfileHandler {
  final ProfileRepository _profileRepository;
  final UserRepository _userRepository;

  ProfileHandler(this._profileRepository, this._userRepository);

  Router get router {
    final router = Router();
    router.get('/&lt;userId&gt;/profile', _getProfile);
    router.post('/&lt;userId&gt;/profile', _createProfile);
    router.put('/&lt;userId&gt;/profile', _updateProfile);
    return router;
  }

  Future&lt;Response&gt; _getProfile(Request request, String userId) async {
    final user = await _userRepository.findById(userId);
    if (user == null) {
      return Response.notFound(
        jsonEncode({'error': 'User not found'}),
        headers: {'Content-Type': 'application/json'},
      );
    }

    final profile = await _profileRepository.findByUserId(userId);
    if (profile == null) {
      return Response.notFound(
        jsonEncode({'error': 'Profile not found'}),
        headers: {'Content-Type': 'application/json'},
      );
    }

    return Response.ok(
      jsonEncode(profile.toJson()),
      headers: {'Content-Type': 'application/json'},
    );
  }

  Future&lt;Response&gt; _createProfile(Request request, String userId) async {
    final user = await _userRepository.findById(userId);
    if (user == null) {
      return Response.notFound(
        jsonEncode({'error': 'User not found'}),
        headers: {'Content-Type': 'application/json'},
      );
    }

    final existing = await _profileRepository.findByUserId(userId);
    if (existing != null) {
      return Response(
        409,
        body: jsonEncode({'error': 'Profile already exists for this user'}),
        headers: {'Content-Type': 'application/json'},
      );
    }

    final body = jsonDecode(await request.readAsString()) as Map&lt;String, dynamic&gt;;

    final profile = await _profileRepository.create(
      userId: userId,
      bio: body['bio'] as String?,
      avatarUrl: body['avatarUrl'] as String?,
      phone: body['phone'] as String?,
      location: body['location'] as String?,
      website: body['website'] as String?,
    );

    return Response(
      201,
      body: jsonEncode(profile.toJson()),
      headers: {'Content-Type': 'application/json'},
    );
  }

  Future&lt;Response&gt; _updateProfile(Request request, String userId) async {
    final body = jsonDecode(await request.readAsString()) as Map&lt;String, dynamic&gt;;

    final profile = await _profileRepository.update(
      userId: userId,
      bio: body['bio'] as String?,
      avatarUrl: body['avatarUrl'] as String?,
      phone: body['phone'] as String?,
      location: body['location'] as String?,
      website: body['website'] as String?,
    );

    if (profile == null) {
      return Response.notFound(
        jsonEncode({'error': 'Profile not found'}),
        headers: {'Content-Type': 'application/json'},
      );
    }

    return Response.ok(
      jsonEncode(profile.toJson()),
      headers: {'Content-Type': 'application/json'},
    );
  }
}
</code></pre>
<h2 id="heading-authentication">Authentication</h2>
<p>With the core user and profile CRUD in place, the next step is securing the API.</p>
<p>Authentication in this project works in two parts: an AuthService handles the cryptographic operations — password hashing and JWT generation and verification — and an AuthHandler exposes the register and login endpoints that clients call to get a token. Once a token is issued, the AuthMiddleware validates it on every protected request before it reaches a handler.</p>
<h3 id="heading-password-hashing">Password Hashing</h3>
<p>Create lib/services/auth_service.dart:</p>
<pre><code class="language-dart">import 'package:bcrypt/bcrypt.dart';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
import '../config/env.dart';
import '../models/user.dart';

class AuthService {
  String hashPassword(String password) {
    return BCrypt.hashpw(password, BCrypt.gensalt());
  }

  bool verifyPassword(String password, String hash) {
    return BCrypt.checkpw(password, hash);
  }

  String generateToken(User user) {
    final jwt = JWT(
      {
        'sub': user.id,
        'email': user.email,
        'iat': DateTime.now().millisecondsSinceEpoch ~/ 1000,
      },
    );

    return jwt.sign(
      SecretKey(Env.jwtSecret),
      expiresIn: Duration(hours: Env.jwtExpiryHours),
    );
  }

  JWT? verifyToken(String token) {
    try {
      return JWT.verify(token, SecretKey(Env.jwtSecret));
    } catch (_) {
      return null;
    }
  }
}
</code></pre>
<p>BCrypt.hashpw generates a salted hash. BCrypt.checkpw verifies a plain password against a stored hash. The salt is embedded in the hash itself – you don't store it separately.</p>
<p>verifyToken returns null on any failure, expired token, invalid signature, or malformed token rather than throwing. This keeps the auth middleware clean.</p>
<h3 id="heading-auth-handlers">Auth Handlers</h3>
<p>Create lib/handlers/auth_handler.dart:</p>
<pre><code class="language-dart">import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:shelf_router/shelf_router.dart';
import '../repositories/user_repository.dart';
import '../services/auth_service.dart';

class AuthHandler {
  final UserRepository _userRepository;
  final AuthService _authService;

  AuthHandler(this._userRepository, this._authService);

  Router get router {
    final router = Router();
    router.post('/register', _register);
    router.post('/login', _login);
    return router;
  }

  Future&lt;Response&gt; _register(Request request) async {
    final body = jsonDecode(await request.readAsString()) as Map&lt;String, dynamic&gt;;

    final email = body['email'] as String?;
    final password = body['password'] as String?;
    final firstName = body['firstName'] as String?;
    final lastName = body['lastName'] as String?;

    if (email == null || password == null || firstName == null || lastName == null) {
      return Response(
        400,
        body: jsonEncode({'error': 'email, password, firstName, and lastName are required'}),
        headers: {'Content-Type': 'application/json'},
      );
    }

    if (password.length &lt; 8) {
      return Response(
        400,
        body: jsonEncode({'error': 'Password must be at least 8 characters'}),
        headers: {'Content-Type': 'application/json'},
      );
    }

    final existing = await _userRepository.findByEmail(email);
    if (existing != null) {
      return Response(
        409,
        body: jsonEncode({'error': 'An account with this email already exists'}),
        headers: {'Content-Type': 'application/json'},
      );
    }

    final passwordHash = _authService.hashPassword(password);

    final user = await _userRepository.create(
      email: email,
      passwordHash: passwordHash,
      firstName: firstName,
      lastName: lastName,
    );

    final token = _authService.generateToken(user);

    return Response(
      201,
      body: jsonEncode({
        'user': user.toJson(),
        'token': token,
      }),
      headers: {'Content-Type': 'application/json'},
    );
  }

  Future&lt;Response&gt; _login(Request request) async {
    final body = jsonDecode(await request.readAsString()) as Map&lt;String, dynamic&gt;;

    final email = body['email'] as String?;
    final password = body['password'] as String?;

    if (email == null || password == null) {
      return Response(
        400,
        body: jsonEncode({'error': 'email and password are required'}),
        headers: {'Content-Type': 'application/json'},
      );
    }

    final user = await _userRepository.findByEmail(email);

    // Deliberately vague error, never confirm whether an email exists
    if (user == null || !_authService.verifyPassword(password, user.passwordHash)) {
      return Response(
        401,
        body: jsonEncode({'error': 'Invalid email or password'}),
        headers: {'Content-Type': 'application/json'},
      );
    }

    final token = _authService.generateToken(user);

    return Response.ok(
      jsonEncode({
        'user': user.toJson(),
        'token': token,
      }),
      headers: {'Content-Type': 'application/json'},
    );
  }
}
</code></pre>
<p>The login error message is deliberately vague: "Invalid email or password" rather than "Email not found" or "Wrong password." Confirming which part is wrong helps attackers enumerate valid accounts.</p>
<h3 id="heading-auth-middleware">Auth Middleware</h3>
<p>Create lib/middleware/auth_middleware.dart:</p>
<pre><code class="language-dart">import 'dart:convert';
import 'package:shelf/shelf.dart';
import '../services/auth_service.dart';

Middleware authMiddleware(AuthService authService) {
  return (Handler innerHandler) {
    return (Request request) async {
      final authHeader = request.headers['authorization'];

      if (authHeader == null || !authHeader.startsWith('Bearer ')) {
        return Response(
          401,
          body: jsonEncode({'error': 'Authorization header missing or malformed'}),
          headers: {'Content-Type': 'application/json'},
        );
      }

      final token = authHeader.substring(7); // Remove 'Bearer '
      final jwt = authService.verifyToken(token);

      if (jwt == null) {
        return Response(
          401,
          body: jsonEncode({'error': 'Invalid or expired token'}),
          headers: {'Content-Type': 'application/json'},
        );
      }

      // Attach the user ID to the request context for downstream handlers
      final updatedRequest = request.change(
        context: {
          ...request.context,
          'userId': jwt.payload['sub'] as String,
          'userEmail': jwt.payload['email'] as String,
        },
      );

      return innerHandler(updatedRequest);
    };
  };
}
</code></pre>
<p>request.change(context: {...}) is how Shelf passes data from middleware to handlers, the equivalent of attaching data to a request in Express or ASP.NET middleware. Any handler downstream can read request.context['userId'] to know which user is authenticated.</p>
<h2 id="heading-error-handling">Error Handling</h2>
<p>No matter how carefully you write your handlers, unexpected failures will happen in production — malformed request bodies, database timeouts, unhandled edge cases.</p>
<p>Rather than letting each handler manage its own error responses individually, we'll centralise error handling in a single middleware that wraps the entire pipeline. This guarantees a consistent error response shape across every endpoint and prevents internal error details from leaking to the client.</p>
<p>Create lib/middleware/error_middleware.dart:</p>
<pre><code class="language-dart">import 'dart:convert';
import 'package:shelf/shelf.dart';

Middleware errorMiddleware() {
  return (Handler innerHandler) {
    return (Request request) async {
      try {
        return await innerHandler(request);
      } on FormatException catch (e) {
        return Response(
          400,
          body: jsonEncode({'error': 'Invalid request body: ${e.message}'}),
          headers: {'Content-Type': 'application/json'},
        );
      } catch (e, stackTrace) {
        // Log the full error and stack trace server-side
        print('Unhandled error: $e');
        print(stackTrace);

        // Never expose internal error details to the client
        return Response(
          500,
          body: jsonEncode({'error': 'An internal server error occurred'}),
          headers: {'Content-Type': 'application/json'},
        );
      }
    };
  };
}
</code></pre>
<p>Create lib/middleware/logger_middleware.dart:</p>
<pre><code class="language-dart">import 'package:shelf/shelf.dart';

Middleware loggerMiddleware() {
  return (Handler innerHandler) {
    return (Request request) async {
      final start = DateTime.now();

      final response = await innerHandler(request);

      final duration = DateTime.now().difference(start).inMilliseconds;
      print(
        '[${DateTime.now().toIso8601String()}] '
        '\({request.method} \){request.url.path} '
        '→ \({response.statusCode} (\){duration}ms)',
      );

      return response;
    };
  };
}
</code></pre>
<h2 id="heading-wiring-everything-together">Wiring Everything Together</h2>
<p>With the handlers, repositories, and middleware all in place, the final step is connecting them into a single running server. The router maps URL prefixes to their handler, the pipeline stacks the middleware in the correct order, and the entry point boots everything up in sequence — loading environment variables, running migrations, and starting the server.</p>
<p>Create lib/router.dart:</p>
<pre><code class="language-dart">import 'package:shelf_router/shelf_router.dart';
import 'handlers/auth_handler.dart';
import 'handlers/user_handler.dart';
import 'handlers/profile_handler.dart';
import 'middleware/auth_middleware.dart';
import 'repositories/user_repository.dart';
import 'repositories/profile_repository.dart';
import 'services/auth_service.dart';

Router createRouter() {
  final userRepository = UserRepository();
  final profileRepository = ProfileRepository();
  final authService = AuthService();

  final authHandler = AuthHandler(userRepository, authService);
  final userHandler = UserHandler(userRepository);
  final profileHandler = ProfileHandler(profileRepository, userRepository);

  final router = Router();

  // Public routes, no auth required
  router.mount('/auth', authHandler.router.call);

  // Protected routes, auth middleware applied
  router.mount(
    '/users',
    Pipeline()
        .addMiddleware(authMiddleware(authService))
        .addHandler(userHandler.router.call),
  );

  router.mount(
    '/users',
    Pipeline()
        .addMiddleware(authMiddleware(authService))
        .addHandler(profileHandler.router.call),
  );

  return router;
}
</code></pre>
<p>Create the entry point bin/server.dart:</p>
<pre><code class="language-dart">import 'dart:io';
import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io;
import '../lib/config/database.dart';
import '../lib/config/env.dart';
import '../lib/middleware/error_middleware.dart';
import '../lib/middleware/logger_middleware.dart';
import '../lib/router.dart';

void main() async {
  // Load environment variables
  Env.load();

  // Run database migrations
  await Database.runMigrations();

  // Build the handler pipeline
  final router = createRouter();

  final handler = Pipeline()
      .addMiddleware(errorMiddleware())
      .addMiddleware(loggerMiddleware())
      .addHandler(router.call);

  // Start the server
  final server = await shelf_io.serve(
    handler,
    InternetAddress.anyIPv4,
    Env.port,
  );

  print('🚀 Server running on port ${server.port}');
}
</code></pre>
<p>Run the server:</p>
<pre><code class="language-bash">dart run bin/server.dart
# ✅ Database connected: localhost:5432/user_profile_api
# ✅ Migration applied: migrations/001_create_users.sql
# ✅ Migration applied: migrations/002_create_profiles.sql
# 🚀 Server running on port 8080
</code></pre>
<h2 id="heading-deployment">Deployment</h2>
<p>The server is running locally and all endpoints are working. Now it's time to ship it.</p>
<p>We'll cover two deployment paths: first packaging the app and database together with Docker Compose for local production testing, then deploying to Fly.io where your API will be accessible over the internet with a managed PostgreSQL database and automatic TLS.</p>
<h3 id="heading-dockerfile">Dockerfile</h3>
<p>Create Dockerfile in the project root:</p>
<pre><code class="language-dockerfile">FROM dart:stable AS build

WORKDIR /app
COPY pubspec.* ./
RUN dart pub get

COPY . .
RUN dart compile exe bin/server.dart -o bin/server

FROM debian:stable-slim

RUN apt-get update &amp;&amp; apt-get install -y ca-certificates &amp;&amp; rm -rf /var/lib/apt/lists/*

WORKDIR /app
COPY --from=build /app/bin/server bin/server
COPY --from=build /app/migrations migrations/

EXPOSE 8080

CMD ["bin/server"]
</code></pre>
<p>This is a multi-stage build. The first stage uses the full Dart SDK image to compile the server to a native binary. The second stage copies only the compiled binary and migrations into a minimal Debian image – no Dart SDK, no source code, no build tools. The final image is lean and production-ready.</p>
<h3 id="heading-docker-compose-for-local-production-testing">Docker Compose for Local Production Testing</h3>
<p>Update docker-compose.yml to include the app alongside the database:</p>
<pre><code class="language-yaml">version: '3.8'

services:
  postgres:
    image: postgres:16-alpine
    container_name: user_profile_db
    environment:
      POSTGRES_DB: user_profile_api
      POSTGRES_USER: dart_user
      POSTGRES_PASSWORD: dart_password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U dart_user -d user_profile_api"]
      interval: 5s
      timeout: 5s
      retries: 5

  api:
    build: .
    container_name: user_profile_api
    ports:
      - "8080:8080"
    environment:
      DB_HOST: postgres
      DB_PORT: 5432
      DB_NAME: user_profile_api
      DB_USER: dart_user
      DB_PASSWORD: dart_password
      JWT_SECRET: local_test_secret_replace_in_production
      JWT_EXPIRY_HOURS: 24
      PORT: 8080
    depends_on:
      postgres:
        condition: service_healthy

volumes:
  postgres_data:
</code></pre>
<p>The healthcheck on the Postgres service ensures that the API container only starts once the database is ready to accept connections (a common production problem when services start simultaneously).</p>
<p>Build and run everything:</p>
<pre><code class="language-bash">docker compose up --build
</code></pre>
<h3 id="heading-deploying-to-flyio">Deploying to Fly.io</h3>
<p>Fly.io is one of the cleanest deployment targets for containerized backend services. It handles global distribution, automatic TLS, and managed PostgreSQL databases.</p>
<p><strong>Step 1 – Install and authenticate:</strong></p>
<pre><code class="language-bash"># macOS
brew install flyctl

# Authenticate
fly auth login
</code></pre>
<p><strong>Step 2 – Launch the app:</strong></p>
<pre><code class="language-bash">fly launch
</code></pre>
<p>Fly detects the Dockerfile automatically and asks a few questions: app name, region, and whether to create a PostgreSQL database. Answer yes to the PostgreSQL prompt, and Fly will provision a managed database and inject the connection string automatically.</p>
<p><strong>Step 3 – Set environment variables:</strong></p>
<pre><code class="language-bash">fly secrets set JWT_SECRET="your_production_secret_here"
fly secrets set JWT_EXPIRY_HOURS="24"
</code></pre>
<p>Database connection variables are set automatically by Fly when it provisions the PostgreSQL cluster.</p>
<p><strong>Step 4 – Deploy:</strong></p>
<pre><code class="language-bash">fly deploy
</code></pre>
<p>Fly builds the Docker image, pushes it to their registry, and deploys it to your chosen region. Once complete:</p>
<pre><code class="language-bash">fly status
# Your app is running at https://your-app-name.fly.dev
</code></pre>
<p><strong>Step 5 – Verify the deployment:</strong></p>
<pre><code class="language-bash">curl https://your-app-name.fly.dev/auth/register \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"password123","firstName":"Seyi","lastName":"Dev"}'
</code></pre>
<h2 id="heading-testing-the-api">Testing the API</h2>
<p>With the server running locally on port 8080, here's the full flow to verify that everything works end to end.</p>
<p>Register a user:</p>
<pre><code class="language-bash">curl http://localhost:8080/auth/register \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{
    "email": "seyi@example.com",
    "password": "securepassword",
    "firstName": "Seyi",
    "lastName": "Dev"
  }'
</code></pre>
<p>Response:</p>
<pre><code class="language-json">{
  "user": {
    "id": "uuid-here",
    "email": "seyi@example.com",
    "firstName": "Seyi",
    "lastName": "Dev",
    "isActive": true,
    "createdAt": "2025-01-01T00:00:00.000Z",
    "updatedAt": "2025-01-01T00:00:00.000Z"
  },
  "token": "eyJhbGci..."
}
</code></pre>
<p>Login:</p>
<pre><code class="language-bash">curl http://localhost:8080/auth/login \
  -X POST \
  -H "Content-Type: application/json" \
  -d '{"email": "seyi@example.com", "password": "securepassword"}'
</code></pre>
<p>Get all users (authenticated):</p>
<pre><code class="language-bash">curl http://localhost:8080/users \
  -H "Authorization: Bearer eyJhbGci..."
</code></pre>
<p>Create a profile:</p>
<pre><code class="language-bash">curl http://localhost:8080/users/{userId}/profile \
  -X POST \
  -H "Authorization: Bearer eyJhbGci..." \
  -H "Content-Type: application/json" \
  -d '{
    "bio": "Flutter engineer turned backend developer",
    "location": "Lagos, Nigeria",
    "website": "https://example.com"
  }'
</code></pre>
<p>Update a user:</p>
<pre><code class="language-bash">curl http://localhost:8080/users/{userId} \
  -X PUT \
  -H "Authorization: Bearer eyJhbGci..." \
  -H "Content-Type: application/json" \
  -d '{"firstName": "Oluwaseyi"}'
</code></pre>
<p>Delete a user:</p>
<pre><code class="language-bash">curl http://localhost:8080/users/{userId} \
  -X DELETE \
  -H "Authorization: Bearer eyJhbGci..."
</code></pre>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You just built and deployed a production-grade REST API in Dart – the same language you already know from Flutter. No new language, no new paradigm. Just Dart running in a different context.</p>
<p>The Shelf mental model (Handlers, Middleware, Pipelines, Routers) is deliberately minimal. It doesn't make decisions for you. It gives you composable primitives and lets you assemble them into exactly the architecture your project needs. That philosophy will feel familiar to Flutter engineers who build their own clean architecture rather than relying on a prescriptive framework.</p>
<p>What you built here – models, repositories, services, handlers, and middleware – is the same separation of concerns you apply in Flutter, applied to the backend. The concepts transfer. The Dart skills transfer. The architecture discipline transfers.</p>
<p>With this, you'll understand that Dart is a powerful language that cuts across both frontend and backend ecosystems. Aside from Shelf, we have Dartfrog and Serverpod which still functions well on the backend side of things. More on those in upcoming articles.</p>
<p>So yeah, try this out and thank me later!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ Advanced Error Handling in Dart: Records, Result Types, Monads, and Freezed Exceptions ]]>
                </title>
                <description>
                    <![CDATA[ Every Dart developer has written this at some point: try {   final user = await repository.getUser(id);   // do something with user } catch (e) {   // what is e? who knows.   print(e.toString()); } I ]]>
                </description>
                <link>https://www.freecodecamp.org/news/advanced-error-handling-in-dart-records-result-types-monads-and-freezed-exceptions/</link>
                <guid isPermaLink="false">6a17657ebadcd8afcb2bcdb4</guid>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ error handling ]]>
                    </category>
                
                    <category>
                        <![CDATA[ exception ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Software Engineering ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Wed, 27 May 2026 21:43:26 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/21795781-af21-4c57-9457-6c58f22af656.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Every Dart developer has written this at some point:</p>
<pre><code class="language-dart">try {
  final user = await repository.getUser(id);
  // do something with user
} catch (e) {
  // what is e? who knows.
  print(e.toString());
}
</code></pre>
<p>It works. It compiles. It ships. And then six months later, a bug report lands in your inbox from a user who got a blank screen instead of an error message, and you spend three hours tracing it back to a <code>catch (e)</code> block that swallowed the failure silently.</p>
<p>This is the fundamental problem with exception-based error handling in Dart. Exceptions are invisible in function signatures. They carry no type information at the call site. The compiler can't help you because it doesn't know a function can fail.</p>
<p>Every failure path is a social contract between the author and the caller — and social contracts break under pressure, in large teams, and at 2am during an incident.</p>
<p>Production applications deserve better than that.</p>
<p>In this article, we're going to walk through a complete, modern approach to error handling in Dart — the kind used in real production Flutter codebases. We'll start with Dart Records as lightweight result containers, build a proper sealed Result type, extend it into the Monad pattern, integrate the <code>dartz</code> package for functional Either types, and finally cap it off with typed, exhaustive exceptions using Freezed.</p>
<p>By the end, failures in your codebase will be typed, visible, compiler-enforced, and impossible to ignore.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ul>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-the-problem-with-exceptions-in-dart">The Problem with Exceptions in Dart</a></p>
</li>
<li><p><a href="#heading-part-1-record-types-as-lightweight-result-containers">Part 1: Record Types as Lightweight Result Containers</a></p>
<ul>
<li><p><a href="#heading-what-are-dart-records">What are Dart Records?</a></p>
</li>
<li><p><a href="#heading-records-as-result-types">Records as Result Types</a></p>
</li>
<li><p><a href="#heading-sealed-classes-as-namespaced-constructors">Sealed Classes as Namespaced Constructors</a></p>
</li>
<li><p><a href="#heading-domain-specific-record-types">Domain-Specific Record Types</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-2-building-a-proper-sealed-result-type">Part 2: Building a Proper Sealed Result Type</a></p>
<ul>
<li><p><a href="#heading-the-appresult-sealed-class">The AppResult Sealed Class</a></p>
</li>
<li><p><a href="#heading-consuming-results-with-when">Consuming Results with when()</a></p>
</li>
<li><p><a href="#heading-why-this-is-better">Why This is Better</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-3-extending-to-the-monad-pattern">Part 3: Extending to the Monad Pattern</a></p>
<ul>
<li><p><a href="#heading-what-makes-something-a-monad">What Makes Something a Monad?</a></p>
</li>
<li><p><a href="#heading-adding-map-and-flatmap">Adding map and flatMap</a></p>
</li>
<li><p><a href="#heading-chaining-operations">Chaining Operations</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-4-either-with-dartz">Part 4: Either with dartz</a></p>
<ul>
<li><p><a href="#heading-what-is-either">What is Either?</a></p>
</li>
<li><p><a href="#heading-using-either-in-practice">Using Either in Practice</a></p>
</li>
<li><p><a href="#heading-bridging-records-and-either">Bridging Records and Either</a></p>
</li>
<li><p><a href="#heading-folding-an-either">Folding an Either</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-5-typed-exceptions-with-freezed">Part 5: Typed Exceptions with Freezed</a></p>
<ul>
<li><p><a href="#heading-why-freezed-for-exceptions">Why Freezed for Exceptions?</a></p>
</li>
<li><p><a href="#heading-building-iexception">Building iException</a></p>
</li>
<li><p><a href="#heading-pattern-matching-on-exception-types">Pattern Matching on Exception Types</a></p>
</li>
<li><p><a href="#heading-a-cleaner-base-getter-pattern">A Cleaner Base Getter Pattern</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-part-6-putting-it-all-together">Part 6: Putting It All Together</a></p>
<ul>
<li><p><a href="#heading-the-full-architecture">The Full Architecture</a></p>
</li>
<li><p><a href="#heading-repository-layer">Repository Layer</a></p>
</li>
<li><p><a href="#heading-domain-layer">Domain Layer</a></p>
</li>
<li><p><a href="#heading-presentation-layer">Presentation Layer</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before starting, you should have:</p>
<ul>
<li><p>A working Flutter project with Dart 3.0 or later</p>
</li>
<li><p>Basic familiarity with Dart generics and async/await</p>
</li>
<li><p>Basic understanding of sealed classes in Dart</p>
</li>
<li><p>The <code>freezed</code>, <code>freezed_annotation</code>, and <code>build_runner</code> packages available</p>
</li>
<li><p>The <code>dartz</code> package available</p>
</li>
<li><p><code>flutter pub run build_runner build</code> working in your project</p>
</li>
</ul>
<h2 id="heading-the-problem-with-exceptions-in-dart">The Problem with Exceptions in Dart</h2>
<p>Let's look at what typical exception-based error handling actually looks like across a full stack:</p>
<pre><code class="language-dart">// Repository
Future&lt;User&gt; getUser(String id) async {
  final response = await dio.get('/users/$id');
  return User.fromJson(response.data);
}

// Use case
Future&lt;User&gt; execute(String id) async {
  return await repository.getUser(id);
}

// ViewModel
Future&lt;void&gt; loadUser(String id) async {
  try {
    final user = await useCase.execute(id);
    state = UserState.loaded(user);
  } catch (e) {
    state = UserState.error(e.toString());
  }
}
</code></pre>
<p>This looks reasonable. But there are serious hidden problems here.</p>
<p><strong>The failure is invisible in the signature:</strong> <code>Future&lt;User&gt;</code> tells the caller "you will get a User." It says nothing about what happens when the network fails, when the token expires, or when the JSON is malformed. The caller has to know — by reading the implementation — that this function can fail.</p>
<p><strong>The compiler can't help you:</strong> If you forget the <code>try/catch</code> in the ViewModel, the app compiles fine. The crash happens at runtime, in production, in front of a real user.</p>
<p><code>catch (e)</code> <strong>catches everything:</strong> A typo in a variable name, a null dereference, a real network failure — they all land in the same catch block. You can't distinguish between them without inspecting the error string, which is fragile.</p>
<p><strong>Errors lose their type across layers:</strong> By the time an <code>UnauthorizedException</code> from the API layer reaches the ViewModel, it's just an <code>Object</code>. All structural information is gone.</p>
<p>The solution is to make failures a first-class part of your function signatures, your type system, and your compiler checks. That is exactly what the patterns in this article do.</p>
<h2 id="heading-part-1-record-types-as-lightweight-result-containers">Part 1: Record Types as Lightweight Result Containers</h2>
<h3 id="heading-what-are-dart-records">What are Dart Records?</h3>
<p>Dart 3.0 introduced Records — anonymous, immutable value types that group multiple fields together without needing a full class definition.</p>
<pre><code class="language-dart">// A record with two named fields
({String name, int age}) person = (name: 'Seyi', age: 28);

print(person.name); // Seyi
print(person.age);  // 28
</code></pre>
<p>Records are structurally typed — two records with the same field names and types are the same type, regardless of where they were defined. They're also immutable and compare by value, not by reference.</p>
<h3 id="heading-records-as-result-types">Records as Result Types</h3>
<p>The simplest application of records in error handling is encoding success and failure as a single return type with nullable fields:</p>
<pre><code class="language-dart">typedef Result&lt;E, T&gt; = ({E? e, T? data});
</code></pre>
<p>This defines a record type with two nullable fields — <code>e</code> for the error and <code>data</code> for the success value. The contract is simple: exactly one of them will be non-null.</p>
<pre><code class="language-dart">// On success — data is present, e is null
Result&lt;String, User&gt; result = (e: null, data: user);

// On failure — e is present, data is null
Result&lt;String, User&gt; result = (e: 'User not found', data: null);
</code></pre>
<p>This is already a significant improvement over exceptions. The return type now tells the caller that this function can produce either data or an error. The failure is part of the signature.</p>
<p>You can define more specific typedefs for different layers of your application:</p>
<pre><code class="language-dart">typedef ApiResult&lt;T, E&gt;      = ({T? data, E? exception});
typedef SecurityResponse     = ({bool? isSecured, String? error});
typedef Repository&lt;T&gt;        = ApiResult&lt;T, iException&gt;;
</code></pre>
<p>Each typedef gives a meaningful name to a record shape, making the intent clear at every call site.</p>
<h3 id="heading-sealed-classes-as-namespaced-constructors">Sealed Classes as Namespaced Constructors</h3>
<p>Creating result records manually every time is repetitive and error-prone. The cleanest solution is to use a sealed class purely as a namespace for static factory methods:</p>
<pre><code class="language-dart">sealed class Res&lt;E, T&gt; {
  static Result&lt;E, T&gt; success&lt;E, T&gt;(T data) =&gt; (e: null, data: data);
  static Result&lt;E, T&gt; failure&lt;E, T&gt;(E e) =&gt; (e: e, data: null);
}
</code></pre>
<p>Notice what <code>sealed</code> is doing here: it's not being used for polymorphism. It can't be instantiated. It exists purely to group two related static methods under a meaningful, non-extendable name.</p>
<p>The call site becomes clean and intentional:</p>
<pre><code class="language-dart">// In a repository
Future&lt;Result&lt;iException, User&gt;&gt; getUser(String id) async {
  try {
    final user = await _api.fetchUser(id);
    return Res.success(user);
  } on NetworkException catch (e) {
    return Res.failure(iException.internet(message: e.message));
  }
}
</code></pre>
<p>The same pattern applies for Dio-specific responses:</p>
<pre><code class="language-dart">sealed class DioResult&lt;T, E&gt; {
  static ApiResult&lt;T, E&gt; success&lt;T, E&gt;(T data) =&gt; (data: data, exception: null);
  static ApiResult&lt;T, E&gt; failure&lt;T, E&gt;(E exception) =&gt; (data: null, exception: exception);
}
</code></pre>
<p>And for repository-level results with a simplified type alias:</p>
<pre><code class="language-dart">// GET&lt;E, T&gt; is just ({E? e, T? res})
typedef New&lt;T&gt; = GET&lt;iException, T&gt;;

sealed class R&lt;E, T&gt; {
  static New&lt;T&gt; success&lt;T&gt;(T data) =&gt; (e: null, res: data);
  static New&lt;T&gt; failed&lt;T&gt;(iException error) =&gt; (e: error, res: null);
}
</code></pre>
<p>Each sealed class namespace has a single responsibility and maps to a single layer of the application.</p>
<h3 id="heading-domain-specific-record-types">Domain-Specific Record Types</h3>
<p>Records also work beautifully for domain-specific result shapes that don't fit a generic success/failure pattern:</p>
<pre><code class="language-dart">typedef SecurityResponse = ({bool? isSecured, String? error});

sealed class Check {
  static SecurityResponse isSecured() =&gt; (isSecured: true, error: null);
  static SecurityResponse isInsecured(String error) =&gt; (isSecured: false, error: error);
}
</code></pre>
<p>Using it:</p>
<pre><code class="language-dart">final check = Check.isSecured();
if (check.isSecured == true) {
  // proceed
}

final check = Check.isInsecured('Certificate validation failed');
print(check.error); // Certificate validation failed
</code></pre>
<p>Clean, readable, and self-documenting. The record shape tells you exactly what the function can return.</p>
<p><strong>The limitation to keep in mind:</strong> Record-based result types require you to manually check which field is non-null. There is no compiler enforcement that you handle both cases, and no built-in way to transform the result without unwrapping it manually. That's where a proper sealed Result type becomes necessary.</p>
<h2 id="heading-part-2-building-a-proper-sealed-result-type">Part 2: Building a Proper Sealed Result Type</h2>
<h3 id="heading-the-appresult-sealed-class">The AppResult Sealed Class</h3>
<p>A sealed Result type goes further than a record — it uses Dart's type system to make the two possible states structurally distinct, and provides a <code>when()</code> method that forces the caller to handle both cases at compile time.</p>
<pre><code class="language-dart">import 'app_failure.dart';

sealed class AppResult&lt;T&gt; {
  const AppResult();

  R when&lt;R&gt;({
    required R Function(T value) success,
    required R Function(AppFailure failure) failure,
  });
}

class AppSuccess&lt;T&gt; extends AppResult&lt;T&gt; {
  const AppSuccess(this.value);

  final T value;

  @override
  R when&lt;R&gt;({
    required R Function(T value) success,
    required R Function(AppFailure failure) failure,
  }) {
    return success(value);
  }
}

class AppFailureResult&lt;T&gt; extends AppResult&lt;T&gt; {
  const AppFailureResult(this.error);

  final AppFailure error;

  @override
  R when&lt;R&gt;({
    required R Function(T value) success,
    required R Function(AppFailure failure) failure,
  }) {
    return failure(error);
  }
}
</code></pre>
<p>Let's walk through the design decisions carefully.</p>
<p><code>sealed class AppResult&lt;T&gt;</code>: <code>sealed</code> means all subtypes must live in the same file and the compiler knows every possible subtype. This is what enables exhaustive pattern matching. <code>&lt;T&gt;</code> is the type of data you get on success.</p>
<p><code>AppSuccess&lt;T&gt;</code>: holds the actual data. When <code>when()</code> is called on an <code>AppSuccess</code>, it always calls the <code>success</code> callback and passes the value through.</p>
<p><code>AppFailureResult&lt;T&gt;</code>: holds an <code>AppFailure</code> (your error model). When <code>when()</code> is called on an <code>AppFailureResult</code>, it always calls the <code>failure</code> callback. Notice it still carries <code>&lt;T&gt;</code> even though there is no value — this makes both subtypes compatible with the same <code>AppResult&lt;T&gt;</code> type.</p>
<p><strong>The</strong> <code>when()</code> <strong>method</strong>: this is the key mechanism. Both callbacks are <code>required</code>. The compiler won't let you call <code>when()</code> without handling both cases. You can't forget the error path. You can't forget the success path. The object itself decides which branch runs — not an if/else in the calling code.</p>
<pre><code class="language-dart">// Repository returning AppResult
Future&lt;AppResult&lt;User&gt;&gt; login(String email, String password) async {
  try {
    final user = await _api.login(email, password);
    return AppSuccess(user);
  } on UnauthorizedException {
    return AppFailureResult(AppFailure.unauthorized());
  } on NetworkException {
    return AppFailureResult(AppFailure.network());
  } catch (e) {
    return AppFailureResult(AppFailure.unknown(e.toString()));
  }
}
</code></pre>
<h3 id="heading-consuming-results-with-when">Consuming Results with <code>when()</code></h3>
<pre><code class="language-dart">final result = await _repository.login(email, password);

result.when(
  success: (user) =&gt; emit(AuthState.authenticated(user)),
  failure: (error) =&gt; emit(AuthState.error(error.message)),
);
</code></pre>
<p>You can also use it to return values:</p>
<pre><code class="language-dart">// Returning a Widget
final widget = result.when(
  success: (user) =&gt; UserProfileCard(user: user),
  failure: (error) =&gt; ErrorView(message: error.message),
);

// Returning a String
final message = result.when(
  success: (data) =&gt; 'Welcome back, ${data.name}',
  failure: (error) =&gt; 'Something went wrong: ${error.message}',
);
</code></pre>
<p>The return type <code>R</code> is inferred — whatever both callbacks return, <code>when()</code> returns. If they return a <code>Widget</code>, you get a <code>Widget</code>. If they return a <code>String</code>, you get a <code>String</code>.</p>
<h3 id="heading-why-this-is-better">Why This is Better</h3>
<table>
<thead>
<tr>
<th></th>
<th>Exceptions</th>
<th>AppResult</th>
</tr>
</thead>
<tbody><tr>
<td>Failure visible in signature</td>
<td>❌</td>
<td>✅</td>
</tr>
<tr>
<td>Compiler enforces handling</td>
<td>❌</td>
<td>✅</td>
</tr>
<tr>
<td>Both paths required at call site</td>
<td>❌</td>
<td>✅</td>
</tr>
<tr>
<td>Type safe across all layers</td>
<td>❌</td>
<td>✅</td>
</tr>
<tr>
<td>Readable and self-documenting</td>
<td>❌</td>
<td>✅</td>
</tr>
</tbody></table>
<h2 id="heading-part-3-extending-to-the-monad-pattern">Part 3: Extending to the Monad Pattern</h2>
<h3 id="heading-what-makes-something-a-monad">What Makes Something a Monad?</h3>
<p>A monad is a pattern from functional programming. In practical terms, a type is monadic when it satisfies three things:</p>
<p><strong>Wrap</strong> — you can put a value into the context.</p>
<pre><code class="language-dart">AppSuccess(user) // wrapping a User into AppResult
</code></pre>
<p><strong>Transform (map)</strong> — you can apply a function to the wrapped value without manually unwrapping it. If the result is a failure, the transformation is skipped and the failure propagates.</p>
<p><strong>Chain (flatMap)</strong> — you can sequence multiple operations that each return the same wrapper type, without nesting. The first failure short-circuits the entire chain.</p>
<p><code>AppResult</code> as defined above satisfies the first rule and the <em>spirit</em> of the second through <code>when()</code>. But without <code>map</code> and <code>flatMap</code>, it's not mechanically monadic. Let's fix that.</p>
<h3 id="heading-adding-map-and-flatmap">Adding <code>map</code> and <code>flatMap</code></h3>
<pre><code class="language-dart">sealed class AppResult&lt;T&gt; {
  const AppResult();

  /// Transform the success value, propagate failure untouched
  AppResult&lt;R&gt; map&lt;R&gt;(R Function(T value) transform) {
    return when(
      success: (value) =&gt; AppSuccess(transform(value)),
      failure: (error) =&gt; AppFailureResult(error),
    );
  }

  /// Chain an operation that itself returns an AppResult
  AppResult&lt;R&gt; flatMap&lt;R&gt;(AppResult&lt;R&gt; Function(T value) transform) {
    return when(
      success: (value) =&gt; transform(value),
      failure: (error) =&gt; AppFailureResult(error),
    );
  }

  R when&lt;R&gt;({
    required R Function(T value) success,
    required R Function(AppFailure failure) failure,
  });
}
</code></pre>
<p><code>map</code> transforms the success value using a regular function. If the result is already a failure, <code>map</code> skips the transformation entirely and passes the failure through unchanged. This is called "failure propagation" — errors flow through the chain automatically.</p>
<p><code>flatMap</code> chains an operation that itself returns an <code>AppResult</code>. This is what allows sequencing — when each step in a process can independently succeed or fail, <code>flatMap</code> connects them so the first failure stops the chain.</p>
<h3 id="heading-chaining-operations">Chaining Operations</h3>
<p>Without monadic chaining, sequential operations that can each fail look like this:</p>
<pre><code class="language-dart">final loginResult = await login(email, password);

loginResult.when(
  success: (user) async {
    final profileResult = await getProfile(user.id);
    profileResult.when(
      success: (profile) async {
        final settingsResult = await loadSettings(profile.settingsId);
        settingsResult.when(
          success: (settings) =&gt; emit(AppState.ready(settings)),
          failure: (error) =&gt; emit(AppState.error(error)),
        );
      },
      failure: (error) =&gt; emit(AppState.error(error)),
    );
  },
  failure: (error) =&gt; emit(AppState.error(error)),
);
</code></pre>
<p>Deeply nested, repetitive error handling on every single step. With <code>flatMap</code>:</p>
<pre><code class="language-dart">final result = (await login(email, password))
    .flatMap((user) =&gt; getProfile(user.id))
    .flatMap((profile) =&gt; loadSettings(profile.settingsId))
    .map((settings) =&gt; settings.theme);

result.when(
  success: (theme) =&gt; emit(AppState.ready(theme)),
  failure: (error) =&gt; emit(AppState.error(error)),
);
</code></pre>
<p>Each step only runs if the previous one succeeded. The first failure short-circuits the entire chain. Error handling happens once at the end, not at every step. This is the full power of the monad pattern applied to real application code.</p>
<h2 id="heading-part-4-either-with-dartz">Part 4: Either with dartz</h2>
<h3 id="heading-what-is-either">What is Either?</h3>
<p><code>Either&lt;L, R&gt;</code> is a type from functional programming that represents one of two possible values — a <code>Left</code> or a <code>Right</code>. By convention:</p>
<ul>
<li><p><code>Left</code> — the failure case</p>
</li>
<li><p><code>Right</code> — the success case</p>
</li>
</ul>
<p>The <code>dartz</code> package brings this and many other functional programming primitives to Dart. Add it to your project:</p>
<pre><code class="language-yaml">dependencies:
  dartz: ^0.10.1
</code></pre>
<p>In the codebase we are building from, <code>Either</code> is used with a type alias that makes the intent explicit:</p>
<pre><code class="language-dart">import 'package:dartz/dartz.dart';

typedef API&lt;T&gt; = Either&lt;T, iException&gt;;
</code></pre>
<p>Note the convention here: <code>Left</code> holds the success value <code>T</code>, and <code>Right</code> holds the failure <code>iException</code>. This is intentionally flipped from the functional programming norm. Both conventions exist in real codebases — what matters is that you're consistent.</p>
<h3 id="heading-using-either-in-practice">Using Either in Practice</h3>
<p>Creating Either values:</p>
<pre><code class="language-dart">// Success — Left holds the data
Either&lt;User, iException&gt; result = Left(user);

// Failure — Right holds the exception
Either&lt;User, iException&gt; result = Right(iException.internet(message: 'No connection'));
</code></pre>
<p>Checking which side you're on:</p>
<pre><code class="language-dart">if (result.isLeft()) {
  final user = result.fold((user) =&gt; user, (_) =&gt; null);
}
</code></pre>
<h3 id="heading-bridging-records-and-either">Bridging Records and Either</h3>
<p>The real power of the <code>API</code> typedef comes from <code>ApiRes</code> — a utility class that converts between the record-based world of your data layer and the Either-based world of your domain layer:</p>
<pre><code class="language-dart">class ApiRes {
  static Future&lt;API&lt;T&gt;&gt; deserialize&lt;T&gt;(ApiResult&lt;T, iException&gt; res) async {
    return (res.data != null)
        ? Left(res.data as T)
        : Right(res.exception!);
  }

  static Future&lt;API&gt; deserializeDynamic(
    ApiResult&lt;dynamic, iException&gt; res,
  ) async {
    return (res.data != null) ? Left(res.data) : Right(res.exception!);
  }
}
</code></pre>
<p><code>ApiResult&lt;T, iException&gt;</code> is your record type from the data layer — a Dio response wrapped with nullable fields. <code>ApiRes.deserialize</code> takes that record and converts it into a proper <code>Either</code>, ready to be used in the domain layer.</p>
<p>In practice, a repository method looks like this:</p>
<pre><code class="language-dart">Future&lt;API&lt;User&gt;&gt; getUser(String id) async {
  // Data layer returns a record
  final res = await _dataSource.fetchUser(id);

  // Convert to Either at the boundary
  return ApiRes.deserialize&lt;User&gt;(res);
}
</code></pre>
<p>The boundary between layers is the conversion point. Inside the data layer, you work with records. At the boundary, you convert. In the domain layer, you work with Either. Each layer has the type that suits it best.</p>
<h3 id="heading-folding-an-either">Folding an Either</h3>
<p><code>dartz</code> provides a <code>fold</code> method on Either that works similarly to <code>when()</code> on <code>AppResult</code>:</p>
<pre><code class="language-dart">final result = await repository.getUser(id);

result.fold(
  (user) =&gt; emit(UserState.loaded(user)),       // Left — success
  (exception) =&gt; emit(UserState.error(exception.message)), // Right — failure
);
</code></pre>
<p><code>dartz</code> also gives you monadic operations out of the box:</p>
<pre><code class="language-dart">// map — transform the Left value
final nameResult = result.map((user) =&gt; user.name);

// flatMap / bind — chain Either-returning operations
final profileResult = result.flatMap(
  (user) =&gt; getProfile(user.id),
);
</code></pre>
<p>The full functional toolkit, ready to use without building it yourself.</p>
<h2 id="heading-part-5-typed-exceptions-with-freezed">Part 5: Typed Exceptions with Freezed</h2>
<h3 id="heading-why-freezed-for-exceptions">Why Freezed for Exceptions?</h3>
<p>Standard Dart exceptions carry almost no useful information:</p>
<pre><code class="language-dart">throw Exception('Something went wrong');
// At the catch site: what went wrong? what type? what code? who knows.
</code></pre>
<p>Even custom exception classes require significant boilerplate to implement properly — <code>==</code>, <code>hashCode</code>, <code>toString</code>, immutability, copyWith. Freezed generates all of that automatically, and adds exhaustive pattern matching on top.</p>
<p>Add the required packages:</p>
<pre><code class="language-yaml">dependencies:
  freezed_annotation: ^2.4.1

dev_dependencies:
  freezed: ^2.4.5
  build_runner: ^2.4.6
</code></pre>
<h3 id="heading-building-iexception">Building iException</h3>
<pre><code class="language-dart">import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'exception.freezed.dart';

@freezed
class iException with _$iException {
  const factory iException.internet({
    required String message,
    int? code,
  }) = InternetException;

  const factory iException.mapper({
    required String message,
    int? code,
  }) = MapperException;

  const factory iException.validation({
    required String message,
    int? code,
  }) = ValidationException;

  const factory iException.unauthorized({
    required String message,
    int? code,
  }) = UnauthorizedException;

  const factory iException.unknown({
    required String message,
    int? code,
  }) = UnknownException;

  const iException._();
}
</code></pre>
<p>Run code generation:</p>
<pre><code class="language-bash">flutter pub run build_runner build --delete-conflicting-outputs
</code></pre>
<p>What Freezed generates from this:</p>
<pre><code class="language-plaintext">iException (sealed base)
├── InternetException    — network failures, no connectivity
├── MapperException      — JSON parsing and deserialization failures
├── ValidationException  — input validation failures
├── UnauthorizedException — auth failures, expired tokens
└── UnknownException     — catch-all for unexpected errors
</code></pre>
<p>Each subclass is fully immutable, has <code>==</code> and <code>hashCode</code> based on its fields, and a proper <code>toString</code>. Creating exceptions is clean and explicit:</p>
<pre><code class="language-dart">iException.internet(message: 'No internet connection')
iException.unauthorized(message: 'Session expired', code: 401)
iException.validation(message: 'Email format is invalid')
iException.mapper(message: 'Failed to parse UserResponse', code: 500)
iException.unknown(message: e.toString())
</code></pre>
<p>The private constructor <code>const iException._()</code> is a Freezed requirement when you add any instance method or getter to the base class — it allows Freezed's generated subclasses to call <code>super._()</code> without exposing a public constructor on the base.</p>
<h3 id="heading-pattern-matching-on-exception-types">Pattern Matching on Exception Types</h3>
<p>Because <code>iException</code> is a Freezed sealed class, you get <code>when</code>, <code>maybeWhen</code>, <code>map</code>, and <code>maybeMap</code> for free from code generation:</p>
<pre><code class="language-dart">exception.when(
  internet: (message, code) =&gt; 'No internet: $message',
  mapper: (message, code) =&gt; 'Parse error: $message',
  validation: (message, code) =&gt; 'Invalid input: $message',
  unauthorized: (message, code) =&gt; 'Unauthorised — please log in again',
  unknown: (message, code) =&gt; 'Unexpected error: $message',
);
</code></pre>
<p>Every case is required. The compiler rejects incomplete matches. You can't accidentally handle only some exception types and silently miss others.</p>
<p>For cases where you only care about specific types:</p>
<pre><code class="language-dart">exception.maybeWhen(
  unauthorized: (message, code) =&gt; _redirectToLogin(),
  orElse: () =&gt; _showGenericError(exception),
);
</code></pre>
<h3 id="heading-a-cleaner-base-getter-pattern">A Cleaner Base Getter Pattern</h3>
<p>One thing worth improving in the base <code>iException</code> is providing a safe <code>message</code> getter that works across all subtypes without throwing <code>UnimplementedError</code>:</p>
<pre><code class="language-dart">const iException._();

String get displayMessage =&gt; when(
  internet: (message, _) =&gt; message,
  mapper: (message, _) =&gt; message,
  validation: (message, _) =&gt; message,
  unauthorized: (message, _) =&gt; message,
  unknown: (message, _) =&gt; message,
);
</code></pre>
<p>Now any code holding an <code>iException</code> — regardless of which subtype — can call <code>.displayMessage</code> safely:</p>
<pre><code class="language-dart">// In a ViewModel or BLoC — no need to pattern match just for the message
emit(ErrorState(message: exception.displayMessage));
</code></pre>
<p>This is significantly cleaner than a base getter that throws <code>UnimplementedError</code> at runtime.</p>
<h2 id="heading-part-6-putting-it-all-together">Part 6: Putting It All Together</h2>
<h3 id="heading-the-full-architecture">The Full Architecture</h3>
<p>Here's how all four patterns connect across a real clean architecture Flutter application:</p>
<pre><code class="language-plaintext">Data Layer
  Dio/HTTP call returns raw response
    └── Wrapped in ApiResult&lt;T, iException&gt; (record type)
          │
          ▼
Repository Layer
  ApiRes.deserialize() converts record → Either&lt;T, iException&gt;
    └── Returns API&lt;T&gt; = Either&lt;T, iException&gt;
          │
          ▼
Domain / Use Case Layer
  AppResult&lt;T&gt; is the standard return type
    └── Sealed class with AppSuccess and AppFailureResult
          │
          ▼
Presentation Layer
  result.when() handles both paths
    └── exception.when() handles all failure types
</code></pre>
<p>Each layer has the result type that suits its responsibility. Conversion happens at the boundaries. The presentation layer always deals with <code>AppResult&lt;T&gt;</code> — it doesn't need to know about Either or records.</p>
<h3 id="heading-repository-layer">Repository Layer</h3>
<pre><code class="language-dart">class AuthRepository {
  final AuthDataSource _dataSource;

  AuthRepository(this._dataSource);

  Future&lt;AppResult&lt;User&gt;&gt; login(String email, String password) async {
    // Data source returns a record
    final res = await _dataSource.login(email, password);

    // Convert to Either at the data/domain boundary
    final either = await ApiRes.deserialize&lt;User&gt;(res);

    // Convert Either to AppResult for the domain layer
    return either.fold(
      (user) =&gt; AppSuccess(user),
      (exception) =&gt; AppFailureResult(exception),
    );
  }

  Future&lt;AppResult&lt;List&lt;User&gt;&gt;&gt; getUsers() async {
    final res = await _dataSource.fetchUsers();
    final either = await ApiRes.deserialize&lt;List&lt;User&gt;&gt;(res);

    return either.fold(
      (users) =&gt; AppSuccess(users),
      (exception) =&gt; AppFailureResult(exception),
    );
  }
}
</code></pre>
<h3 id="heading-domain-layer">Domain Layer</h3>
<pre><code class="language-dart">class LoginUseCase {
  final AuthRepository _repository;

  LoginUseCase(this._repository);

  Future&lt;AppResult&lt;User&gt;&gt; execute(String email, String password) async {
    if (email.isEmpty || password.isEmpty) {
      return AppFailureResult(
        iException.validation(message: 'Email and password are required'),
      );
    }

    return _repository.login(email, password);
  }
}
</code></pre>
<p>The use case adds its own validation layer — returning a <code>ValidationException</code> before even hitting the repository. All failures flow through the same <code>AppResult&lt;T&gt;</code> type regardless of where they originated.</p>
<h3 id="heading-presentation-layer">Presentation Layer</h3>
<pre><code class="language-dart">class AuthViewModel extends ChangeNotifier {
  final LoginUseCase _loginUseCase;

  AuthViewModel(this._loginUseCase);

  AuthState _state = const AuthState.idle();
  AuthState get state =&gt; _state;

  Future&lt;void&gt; login(String email, String password) async {
    _state = const AuthState.loading();
    notifyListeners();

    final result = await _loginUseCase.execute(email, password);

    result.when(
      success: (user) {
        _state = AuthState.authenticated(user);
      },
      failure: (exception) {
        // Pattern match on the exception type for specific handling
        final message = exception.when(
          internet: (msg, _) =&gt; 'No internet connection. Please check your network.',
          unauthorized: (msg, _) =&gt; 'Your session has expired. Please log in again.',
          validation: (msg, _) =&gt; msg,
          mapper: (msg, _) =&gt; 'Something went wrong. Please try again.',
          unknown: (msg, _) =&gt; 'An unexpected error occurred.',
        );

        _state = AuthState.error(message);
      },
    );

    notifyListeners();
  }
}
</code></pre>
<p>Two levels of exhaustive pattern matching — one for the result, one for the exception type. Every possible failure has a specific, user-friendly message. The compiler guarantees nothing is missed.</p>
<p>And using the monadic chain from Part 3 for a multi-step flow:</p>
<pre><code class="language-java">Future&lt;void&gt; loadDashboard(String userId) async {
  _state = const DashboardState.loading();
  notifyListeners();

  final result = (await _userRepo.getUser(userId))
      .flatMap((user) =&gt; _profileRepo.getProfile(user.profileId))
      .flatMap((profile) =&gt; _settingsRepo.loadSettings(profile.settingsId))
      .map((settings) =&gt; DashboardData(settings: settings));

  result.when(
    success: (data) =&gt; _state = DashboardState.loaded(data),
    failure: (exception) =&gt; _state = DashboardState.error(
      exception.displayMessage,
    ),
  );

  notifyListeners();
}
</code></pre>
<p>Three sequential async operations, each of which can independently fail, handled in a clean chain with a single error handler at the end. This is what production-grade error handling looks like.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Error handling is one of those things that every codebase has, but few codebases have done well. The default in Dart , throwing and catching exceptions, is convenient for small projects and becomes a liability at scale. Failures become invisible, type information is lost across layers, and the compiler can't help you when something goes wrong.</p>
<p>The patterns in this article change that entirely.</p>
<p>Records give you lightweight result containers with zero boilerplate — perfect for layer-specific result types and domain-specific responses. Sealed Result types bring compiler enforcement — both paths are required, no failure can be silently ignored. The Monad pattern adds the ability to chain sequential operations cleanly, with automatic failure propagation through the chain. Either with <code>dartz</code> brings the full functional toolkit and a clean boundary type between your data and domain layers. And Freezed exceptions give your failure states structure, immutability, and exhaustive pattern matching, so every error type is handled explicitly and nothing slips through.</p>
<p>None of these patterns are complicated once you understand the problem they solve. And the problem they solve – invisible, unenforceable, type-unsafe error handling – is one of the most common sources of production bugs in Flutter applications.</p>
<p>The next step is taking one of these patterns into a real project. Using these will totally transform the error handling story and processes of your entire code base.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
