<?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>Fri, 24 Jul 2026 20:07:53 +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[ 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>
        
            <item>
                <title>
                    <![CDATA[ Learn Command Line Interface (CLI) Development with Dart: From Zero to a Fully Published Developer Tool ]]>
                </title>
                <description>
                    <![CDATA[ Most developers spend a significant portion of their day in the terminal. They run flutter build, push with git, manage packages with dart pub, and orchestrate pipelines from the command line. Every o ]]>
                </description>
                <link>https://www.freecodecamp.org/news/learn-command-line-interface-cli-development-with-dart-from-zero-to-a-fully-published-developer-tool/</link>
                <guid isPermaLink="false">69fe3149f239332df4fdfd46</guid>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ cli ]]>
                    </category>
                
                    <category>
                        <![CDATA[ command line ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Software Engineering ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Fri, 08 May 2026 18:54:01 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/a4c564c2-f5f3-4824-b4e7-d103b5fc488e.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Most developers spend a significant portion of their day in the terminal. They run <code>flutter build</code>, push with <code>git</code>, manage packages with <code>dart pub</code>, and orchestrate pipelines from the command line. Every one of those tools is a CLI, or command line interface: a program that lives in the terminal and responds to text commands.</p>
<p>Yet most developers have never built one.</p>
<p>That's a missed opportunity. CLI tools are one of the most practical things a developer can ship. They automate repetitive workflows, standardise processes across teams, and, when published, become tangible artifacts that the developer community can discover, install, and use.</p>
<p>In this handbook, you'll go from zero to building a fully distributed Dart CLI tool. We'll start with the fundamentals – how CLIs work, how Dart receives and processes terminal input, and the core syntax you need to know. Then we'll build three progressively complex CLIs, starting with the basics and finishing with a real-world API request runner. Finally, we will cover every distribution path available, from <code>pub.dev</code> to compiled binaries, Homebrew taps, Docker, and local team activation.</p>
<p>By the end of the guide, you'll understand both how to build a CLI tool in Dart as well as how to ship it so other developers can actually use it.</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-cli-and-why-should-you-build-one">What is a CLI and Why Should You Build One?</a></p>
</li>
<li><p><a href="#heading-cli-syntax-anatomy">CLI Syntax Anatomy</a></p>
</li>
<li><p><a href="#heading-how-dart-receives-terminal-input">How Dart Receives Terminal Input</a></p>
</li>
<li><p><a href="#heading-core-cli-concepts-in-dart">Core CLI Concepts in Dart</a></p>
<ul>
<li><p><a href="#heading-stdout-stderr-and-stdin">stdout, stderr, and stdin</a></p>
</li>
<li><p><a href="#heading-exit-codes">Exit Codes</a></p>
</li>
<li><p><a href="#heading-environment-variables">Environment Variables</a></p>
</li>
<li><p><a href="#heading-file-and-directory-operations">File and Directory Operations</a></p>
</li>
<li><p><a href="#heading-running-external-processes">Running External Processes</a></p>
</li>
<li><p><a href="#heading-platform-detection">Platform Detection</a></p>
</li>
<li><p><a href="#heading-async-in-cli">Async in CLI</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-setting-up-your-dart-cli-project">Setting Up Your Dart CLI Project</a></p>
</li>
<li><p><a href="#heading-cli-1-hello-cli-the-fundamentals">CLI 1 — Hello CLI: The Fundamentals</a></p>
</li>
<li><p><a href="#heading-cli-2-darttodo-a-terminal-task-manager">CLI 2 — dart_todo: A Terminal Task Manager</a></p>
<ul>
<li><p><a href="#heading-introducing-the-args-package">Introducing the args Package</a></p>
</li>
<li><p><a href="#heading-building-darttodo">Building dart_todo</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-cli-3-darthttp-a-lightweight-api-request-runner">CLI 3 — dart_http: A Lightweight API Request Runner</a></p>
<ul>
<li><a href="#heading-building-darthttp">Building dart_http</a></li>
</ul>
</li>
<li><p><a href="#heading-adding-color-and-polish-to-your-cli">Adding Color and Polish to Your CLI</a></p>
</li>
<li><p><a href="#heading-testing-your-cli-tool">Testing Your CLI Tool</a></p>
</li>
<li><p><a href="#heading-deploying-and-distributing-your-cli">Deploying and Distributing Your CLI</a></p>
<ul>
<li><p><a href="#heading-mode-1-pubdev-public-package-distribution">Mode 1: pub.dev — Public Package Distribution</a></p>
</li>
<li><p><a href="#heading-mode-2-local-path-activation">Mode 2: Local Path Activation</a></p>
</li>
<li><p><a href="#heading-mode-3-compiled-binary-via-github-releases">Mode 3: Compiled Binary via GitHub Releases</a></p>
</li>
<li><p><a href="#heading-mode-4-homebrew-tap">Mode 4: Homebrew Tap</a></p>
</li>
<li><p><a href="#heading-mode-5-docker">Mode 5: Docker</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-choosing-the-right-distribution-mode">Choosing the Right Distribution Mode</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>Dart SDK installed (<code>dart --version</code> should work in your terminal)</p>
</li>
<li><p>Basic familiarity with Dart syntax</p>
</li>
<li><p>Comfort with the terminal and running commands</p>
</li>
<li><p>A pub.dev account (for the publishing section)</p>
</li>
<li><p>A GitHub account (for the binary distribution section)</p>
</li>
</ul>
<h2 id="heading-what-is-a-cli-and-why-should-you-build-one">What is a CLI and Why Should You Build One?</h2>
<p>A CLI (or <strong>Command Line Interface</strong>) is a program you interact with entirely through text commands in a terminal, rather than through buttons and screens in a graphical interface.</p>
<p>Many of the tools you likely already rely on as a developer are CLI tools:</p>
<pre><code class="language-yaml">flutter build apk
git commit -m "fix: auth flow"
dart pub get
npm install
</code></pre>
<p>Flutter, Git, Dart, npm – all CLIs. You are already a CLI user every single day. This article is about becoming a CLI builder.</p>
<p>There are three strong reasons to build CLI tools as a developer:</p>
<ol>
<li><p><strong>Automating repetitive work:</strong> Anything you type more than twice a week is a candidate for automation. Generating boilerplate folder structures, running sequences of commands, scaffolding files, checking environments before a build a CLI turns a seven-step manual process into a single command.</p>
</li>
<li><p><strong>Standardising team workflows:</strong> Instead of a README that says "run these commands in this order," you ship one command that does all of it – consistently, every time, with no room for human error or a missed step.</p>
</li>
<li><p><strong>Building and publishing tooling.</strong> A published Dart CLI package is a tangible artifact. It shows up on pub.dev, gets installed and used by other developers, and communicates real engineering depth in a way that a portfolio or resume cannot.</p>
</li>
</ol>
<h2 id="heading-cli-syntax-anatomy">CLI Syntax Anatomy</h2>
<p>Before writing a single line of code, it helps to understand the structure of a CLI command. Every command follows a consistent pattern:</p>
<pre><code class="language-bash">tool [subcommand] [arguments] [options/flags]
</code></pre>
<p>Breaking down a real example:</p>
<pre><code class="language-bash">flutter build apk --release --obfuscate
│       │     │   │
tool    sub   arg  flags
</code></pre>
<ul>
<li><p><strong>Tool</strong> — the program itself (<code>flutter</code>, <code>dart</code>, <code>git</code>)</p>
</li>
<li><p><strong>Subcommand</strong> — the action being performed (<code>build</code>, <code>run</code>, <code>pub</code>)</p>
</li>
<li><p><strong>Arguments</strong> — what the action operates on (<code>apk</code>, <code>main.dart</code>, a filename)</p>
</li>
<li><p><strong>Flags and Options</strong> — modifiers that change behaviour</p>
</li>
</ul>
<p>There are two types of options:</p>
<pre><code class="language-plaintext">--release              # Boolean flag — either present or absent

--output=build/app     # Key-value option — name and a value
-v                     # Short flag — single hyphen, single character
</code></pre>
<p>This is the anatomy your CLIs will follow. Understanding it before writing any code means you will design your commands intentionally rather than stumbling into structure by accident.</p>
<h2 id="heading-how-dart-receives-terminal-input">How Dart Receives Terminal Input</h2>
<p>In Dart, everything the user types after your tool name is passed into your program through the <code>main</code> function:</p>
<pre><code class="language-dart">void main(List&lt;String&gt; args) {
  print(args);
}
</code></pre>
<p>Run it:</p>
<pre><code class="language-bash">dart run bin/mytool.dart hello world --name=Seyi
# [hello, world, --name=Seyi]
</code></pre>
<p>That <code>List&lt;String&gt; args</code> is just a list of strings. Each word or flag the user typed becomes an element in that list. Everything else you build on top of a CLI subcommands, flags, validation — is ultimately just processing this list.</p>
<h2 id="heading-core-cli-concepts-in-dart">Core CLI Concepts in Dart</h2>
<p>Before building anything, there's a set of foundational concepts that every CLI developer needs to understand. These are the building blocks that everything else sits on top of.</p>
<h3 id="heading-stdout-stderr-and-stdin">stdout, stderr, and stdin</h3>
<p>Most developers use <code>print()</code> for all output when they start building CLIs. That works for learning but it's incorrect in production.</p>
<p>There are two separate output streams in a terminal program:</p>
<ul>
<li><p><code>stdout</code> — regular output, meant for the user</p>
</li>
<li><p><code>stderr</code> — error output, meant for diagnostic messages and failures</p>
</li>
</ul>
<pre><code class="language-dart">import 'dart:io';

void main(List&lt;String&gt; args) {
  if (args.isEmpty) {
    stderr.writeln('Error: no arguments provided');
    exit(1);
  }

  stdout.writeln('Processing: ${args[0]}');
}
</code></pre>
<p>Keeping these separate matters because users can redirect stdout to a file without errors polluting it:</p>
<pre><code class="language-bash">dart run bin/tool.dart &gt; output.txt
# Errors still appear in the terminal
# Normal output goes cleanly to the file
</code></pre>
<p>Tools like <code>git</code>, <code>flutter</code>, and <code>curl</code> all do this correctly. Your CLI should too.</p>
<p><code>stdin</code> is the third stream — reading input from the user interactively at runtime:</p>
<pre><code class="language-dart">import 'dart:io';

void main() {
  stdout.write('Enter your name: ');
  final name = stdin.readLineSync();

  if (name == null || name.trim().isEmpty) {
    stderr.writeln('Error: no name provided');
    exit(1);
  }

  stdout.writeln('Hello, $name!');
}
</code></pre>
<p><code>stdout.write</code> (without <code>ln</code>) keeps the cursor on the same line so the user types right after the prompt. <code>stdin.readLineSync()</code> blocks until the user presses Enter and returns the typed string, or <code>null</code> if the stream closes unexpectedly. Always handle the null case.</p>
<h3 id="heading-exit-codes">Exit Codes</h3>
<p>Every program returns an exit code when it finishes. This is how the shell – and any script or CI system calling your tool – knows whether it succeeded or failed.</p>
<pre><code class="language-dart">import 'dart:io';

void main(List&lt;String&gt; args) {
  if (args.isEmpty) {
    stderr.writeln('Error: please provide an argument');
    exit(1); // failure
  }

  stdout.writeln('Done');
  exit(0); // success — also the default if you don't call exit()
}
</code></pre>
<p>The conventions are:</p>
<ul>
<li><p><code>0</code> — success</p>
</li>
<li><p><code>1</code> — general failure</p>
</li>
<li><p><code>2</code> — incorrect usage (wrong arguments, missing flags)</p>
</li>
</ul>
<p>Exit codes are critical when your CLI is called inside shell scripts or GitHub Actions workflows. A non-zero exit code stops a pipeline immediately. That's exactly the behaviour you want from a quality gate or a validation step.</p>
<h3 id="heading-environment-variables">Environment Variables</h3>
<p>Your CLI can read environment variables set in the user's shell:</p>
<pre><code class="language-dart">import 'dart:io';

void main() {
  final token = Platform.environment['API_TOKEN'];

  if (token == null) {
    stderr.writeln('Error: API_TOKEN environment variable is not set');
    exit(1);
  }

  stdout.writeln('Token found — proceeding...');
}
</code></pre>
<p>Set it in the terminal and run:</p>
<pre><code class="language-bash">export API_TOKEN=mytoken123
dart run bin/tool.dart
# Token found — proceeding...
</code></pre>
<p>This pattern is essential for CLI tools that interact with APIs, cloud services, or CI environments where credentials should never be hardcoded.</p>
<h3 id="heading-file-and-directory-operations">File and Directory Operations</h3>
<p>Many CLI tools read from or write to the file system. Dart's <code>dart:io</code> library covers everything you need:</p>
<pre><code class="language-dart">import 'dart:io';

void main(List&lt;String&gt; args) {
  if (args.isEmpty) {
    stderr.writeln('Usage: tool &lt;filename&gt;');
    exit(2);
  }

  final file = File(args[0]);

  if (!file.existsSync()) {
    stderr.writeln('Error: "${args[0]}" not found');
    exit(1);
  }

  final contents = file.readAsStringSync();
  stdout.writeln(contents);

  final output = File('output.txt');
  output.writeAsStringSync('Processed:\n$contents');
  stdout.writeln('Written to output.txt');
}
</code></pre>
<p>Working with directories:</p>
<pre><code class="language-dart">import 'dart:io';

void main() {
  // Where the command was run from
  final cwd = Directory.current.path;
  stdout.writeln('Working directory: $cwd');

  // Create a directory relative to current location
  final dir = Directory('$cwd/generated');

  if (!dir.existsSync()) {
    dir.createSync(recursive: true);
    stdout.writeln('Created: ${dir.path}');
  } else {
    stdout.writeln('Already exists: ${dir.path}');
  }
}
</code></pre>
<p>The <code>recursive: true</code> flag on <code>createSync</code> means it creates all intermediate directories — equivalent to <code>mkdir -p</code> in bash.</p>
<h3 id="heading-running-external-processes">Running External Processes</h3>
<p>One of the most powerful things a CLI can do is call other programs. Your Dart CLI can run <code>git</code>, <code>flutter</code>, <code>dart</code>, or any shell command programmatically:</p>
<pre><code class="language-dart">import 'dart:io';

void main() async {
  // Run a command and wait for it to finish
  final result = await Process.run('dart', ['pub', 'get']);

  stdout.write(result.stdout);

  if (result.exitCode != 0) {
    stderr.write(result.stderr);
    exit(result.exitCode);
  }

  stdout.writeln('Dependencies installed successfully');
}
</code></pre>
<p>For long-running commands where you want output to stream live as it happens:</p>
<pre><code class="language-dart">import 'dart:io';

void main() async {
  final process = await Process.start('flutter', ['build', 'apk']);

  // Pipe output directly to the terminal in real time
  process.stdout.pipe(stdout);
  process.stderr.pipe(stderr);

  final exitCode = await process.exitCode;
  exit(exitCode);
}
</code></pre>
<p><code>Process.run</code> — waits for completion, returns all output at once. Use for short commands.</p>
<p><code>Process.start</code> — streams output live as it arrives. Use for long-running commands where the user needs to see progress.</p>
<h3 id="heading-platform-detection">Platform Detection</h3>
<p>Sometimes your CLI needs to behave differently depending on the operating system it is running on:</p>
<pre><code class="language-dart">import 'dart:io';

void main() {
  if (Platform.isWindows) {
    stdout.writeln('Running on Windows');
  } else if (Platform.isMacOS) {
    stdout.writeln('Running on macOS');
  } else if (Platform.isLinux) {
    stdout.writeln('Running on Linux');
  }

  // Useful for path handling across operating systems
  stdout.writeln(Platform.pathSeparator); // \ on Windows, / elsewhere
  stdout.writeln(Platform.operatingSystem); // 'macos', 'linux', 'windows'
}
</code></pre>
<p>This matters when your CLI creates files, resolves paths, or calls shell commands that differ between operating systems.</p>
<h3 id="heading-async-in-cli">Async in CLI</h3>
<p>Dart CLIs support <code>async/await</code> natively. Any <code>main</code> function can be made async:</p>
<pre><code class="language-dart">import 'dart:io';

void main() async {
  stdout.writeln('Starting...');

  await Future.delayed(const Duration(seconds: 1)); // simulating async work

  stdout.writeln('Done');
}
</code></pre>
<p>Any operation involving file I/O, HTTP requests, or spawning processes will be asynchronous. Get comfortable with async <code>main</code> functions early — you'll use them constantly.</p>
<h2 id="heading-setting-up-your-dart-cli-project">Setting Up Your Dart CLI Project</h2>
<p>Create a new Dart console project:</p>
<pre><code class="language-bash">dart create -t console my_cli_tool
cd my_cli_tool
</code></pre>
<p>This generates a clean structure:</p>
<pre><code class="language-plaintext">my_cli_tool/
  bin/
    my_cli_tool.dart    ← entry point
  lib/                  ← shared library code
  test/                 ← tests
  pubspec.yaml
  README.md
</code></pre>
<p>The <code>bin/</code> directory is where your executable entry point lives. The <code>lib/</code> directory is where you put everything else — commands, utilities, models — that <code>bin/</code> imports and uses.</p>
<p>Open <code>pubspec.yaml</code>. You'll need to add an <code>executables</code> block before publishing:</p>
<pre><code class="language-yaml">name: my_cli_tool
description: A sample CLI tool built with Dart
version: 1.0.0

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

executables:
  my_cli_tool: my_cli_tool  # executable name: bin file name

dependencies:
  args: ^2.4.2

dev_dependencies:
  lints: ^3.0.0
  test: ^1.24.0
</code></pre>
<p>The <code>executables</code> block is what makes <code>dart pub global activate my_cli_tool</code> work. It tells Dart which script in <code>bin/</code> to expose as a runnable command after installation.</p>
<h2 id="heading-cli-1-hello-cli-the-fundamentals">CLI 1 — Hello CLI: The Fundamentals</h2>
<p>This first CLI uses pure Dart — no packages. The goal is to get comfortable with args, subcommands, input validation, and exit codes before introducing any external dependencies.</p>
<p>Replace the contents of <code>bin/my_cli_tool.dart</code>:</p>
<pre><code class="language-dart">import 'dart:io';

void main(List&lt;String&gt; args) {
  if (args.isEmpty) {
    printHelp();
    exit(0);
  }

  final command = args[0];

  switch (command) {
    case 'greet':
      handleGreet(args.sublist(1));
    case 'time':
      handleTime();
    case 'echo':
      handleEcho(args.sublist(1));
    case 'help':
      printHelp();
    default:
      stderr.writeln('Unknown command: "$command"');
      stderr.writeln('Run "mytool help" to see available commands.');
      exit(1);
  }
}

void handleGreet(List&lt;String&gt; args) {
  if (args.isEmpty) {
    stderr.writeln('Usage: mytool greet &lt;name&gt;');
    exit(2);
  }

  final name = args[0];
  stdout.writeln('Hello, $name! Welcome to your first Dart CLI.');
}

void handleTime() {
  final now = DateTime.now();
  stdout.writeln(
    'Current time: ${now.hour.toString().padLeft(2, '0')}:'
    '${now.minute.toString().padLeft(2, '0')}:'
    '${now.second.toString().padLeft(2, '0')}',
  );
}

void handleEcho(List&lt;String&gt; args) {
  if (args.isEmpty) {
    stderr.writeln('Usage: mytool echo &lt;message&gt;');
    exit(2);
  }

  stdout.writeln(args.join(' '));
}

void printHelp() {
  stdout.writeln('''
mytool — a simple Dart CLI

Usage:
  mytool &lt;command&gt; [arguments]

Commands:
  greet &lt;name&gt;      Greet someone by name
  time              Show the current time
  echo &lt;message&gt;    Echo a message back to the terminal
  help              Show this help message

Examples:
  mytool greet Seyi
  mytool echo "Hello from the terminal"
  mytool time
  ''');
}
</code></pre>
<p>Run it:</p>
<pre><code class="language-bash">dart run bin/my_cli_tool.dart help

dart run bin/my_cli_tool.dart greet Seyi
# Hello, Seyi! Welcome to your first Dart CLI.

dart run bin/my_cli_tool.dart time
# Current time: 14:32:10

dart run bin/my_cli_tool.dart echo "Dart CLIs are powerful"
# Dart CLIs are powerful

dart run bin/my_cli_tool.dart unknown
# Unknown command: "unknown"
# Run "mytool help" to see available commands.
</code></pre>
<p>Three things this CLI demonstrates that are worth internalising:</p>
<ol>
<li><p><strong>Subcommands are just a switch on</strong> <code>args[0]</code><strong>.</strong> The pattern is simple and scalable — add a new <code>case</code> to add a new command.</p>
</li>
<li><p><code>args.sublist(1)</code> <strong>passes remaining args to the handler.</strong> When <code>greet</code> receives <code>['greet', 'Seyi']</code>, it calls <code>handleGreet(['Seyi'])</code> — clean and isolated.</p>
</li>
<li><p><strong>Every error path has a message and a non-zero exit code.</strong> The user always knows what went wrong and what to do next.</p>
</li>
</ol>
<h2 id="heading-cli-2-darttodo-a-terminal-task-manager">CLI 2 — dart_todo: A Terminal Task Manager</h2>
<p>This CLI introduces the <code>args</code> package, JSON file persistence, and structured terminal output. It's meaningfully more complex than CLI 1 and reflects real patterns you will use in production tools.</p>
<h3 id="heading-introducing-the-args-package">Introducing the args Package</h3>
<p>Manually parsing <code>List&lt;String&gt; args</code> works for simple cases, but breaks down quickly when you add flags like <code>--priority=high</code>, boolean options like <code>--done</code>, or commands with multiple optional arguments.</p>
<p>The <code>args</code> package handles all of that cleanly.</p>
<p>Add it to your <code>pubspec.yaml</code>:</p>
<pre><code class="language-yaml">dependencies:
  args: ^2.4.2
</code></pre>
<p>Run:</p>
<pre><code class="language-bash">dart pub get
</code></pre>
<p>The core concept in <code>args</code> is the <code>ArgParser</code>. You define what your CLI accepts, and <code>args</code> handles parsing, validation, and generating help text automatically:</p>
<pre><code class="language-dart">import 'package:args/args.dart';

void main(List&lt;String&gt; arguments) {
  final parser = ArgParser()
    ..addCommand('add')
    ..addCommand('list')
    ..addFlag('help', abbr: 'h', negatable: false);

  final results = parser.parse(arguments);

  if (results['help'] as bool) {
    print(parser.usage);
    return;
  }
}
</code></pre>
<p>For more complex CLIs with subcommands that each have their own flags, use <code>ArgParser</code> per command:</p>
<pre><code class="language-dart">final parser = ArgParser();

final addCommand = ArgParser()
  ..addOption('priority', abbr: 'p', defaultsTo: 'normal');

parser.addCommand('add', addCommand);
</code></pre>
<h3 id="heading-building-darttodo">Building dart_todo</h3>
<p>Create a fresh project:</p>
<pre><code class="language-bash">dart create -t console dart_todo
cd dart_todo
</code></pre>
<p>Update <code>pubspec.yaml</code>:</p>
<pre><code class="language-yaml">name: dart_todo
description: A terminal task manager built with Dart
version: 1.0.0

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

executables:
  dart_todo: dart_todo

dependencies:
  args: ^2.4.2

dev_dependencies:
  lints: ^3.0.0
  test: ^1.24.0
</code></pre>
<p>Run <code>dart pub get</code>.</p>
<p>Create the folder structure:</p>
<pre><code class="language-plaintext">dart_todo/
  bin/
    dart_todo.dart
  lib/
    models/
      task.dart
    storage/
      task_storage.dart
    commands/
      add_command.dart
      list_command.dart
      complete_command.dart
      delete_command.dart
      clear_command.dart
  pubspec.yaml
</code></pre>
<h4 id="heading-step-1-the-task-model-libmodelstaskdart">Step 1 — The Task Model (<code>lib/models/task.dart</code>)</h4>
<pre><code class="language-dart">class Task {
  final int id;
  final String title;
  final String priority;
  final bool isComplete;
  final DateTime createdAt;

  Task({
    required this.id,
    required this.title,
    required this.priority,
    this.isComplete = false,
    required this.createdAt,
  });

  Task copyWith({bool? isComplete}) {
    return Task(
      id: id,
      title: title,
      priority: priority,
      isComplete: isComplete ?? this.isComplete,
      createdAt: createdAt,
    );
  }

  Map&lt;String, dynamic&gt; toJson() =&gt; {
        'id': id,
        'title': title,
        'priority': priority,
        'isComplete': isComplete,
        'createdAt': createdAt.toIso8601String(),
      };

  factory Task.fromJson(Map&lt;String, dynamic&gt; json) =&gt; Task(
        id: json['id'] as int,
        title: json['title'] as String,
        priority: json['priority'] as String,
        isComplete: json['isComplete'] as bool,
        createdAt: DateTime.parse(json['createdAt'] as String),
      );
}
</code></pre>
<h4 id="heading-step-2-storage-libstoragetaskstoragedart">Step 2 — Storage (<code>lib/storage/task_storage.dart</code>)</h4>
<p>This class handles reading and writing tasks to a local JSON file so they persist between CLI runs:</p>
<pre><code class="language-dart">import 'dart:convert';
import 'dart:io';

import '../models/task.dart';

class TaskStorage {
  static final _file = File(
    '${Platform.environment['HOME'] ?? Directory.current.path}/.dart_todo.json',
  );

  static List&lt;Task&gt; loadAll() {
    if (!_file.existsSync()) return [];

    try {
      final content = _file.readAsStringSync();
      final List&lt;dynamic&gt; json = jsonDecode(content) as List&lt;dynamic&gt;;
      return json
          .map((e) =&gt; Task.fromJson(e as Map&lt;String, dynamic&gt;))
          .toList();
    } catch (_) {
      return [];
    }
  }

  static void saveAll(List&lt;Task&gt; tasks) {
    final json = jsonEncode(tasks.map((t) =&gt; t.toJson()).toList());
    _file.writeAsStringSync(json);
  }
}
</code></pre>
<p>Tasks are stored in a hidden JSON file in the user's home directory — a common pattern for CLI tools that need lightweight local persistence.</p>
<h4 id="heading-step-3-commands">Step 3 — Commands</h4>
<p><code>lib/commands/add_command.dart</code>:</p>
<pre><code class="language-dart">import 'dart:io';

import '../models/task.dart';
import '../storage/task_storage.dart';

void runAdd(List&lt;String&gt; args, String priority) {
  if (args.isEmpty) {
    stderr.writeln('Usage: dart_todo add &lt;title&gt; [--priority=high|normal|low]');
    exit(2);
  }

  final title = args.join(' ');
  final tasks = TaskStorage.loadAll();

  final newTask = Task(
    id: tasks.isEmpty ? 1 : tasks.last.id + 1,
    title: title,
    priority: priority,
    createdAt: DateTime.now(),
  );

  tasks.add(newTask);
  TaskStorage.saveAll(tasks);

  stdout.writeln('Added task #\({newTask.id}: "\)title" [$priority]');
}
</code></pre>
<p><code>lib/commands/list_command.dart</code>:</p>
<pre><code class="language-cpp">import 'dart:io';

import '../storage/task_storage.dart';

void runList() {
  final tasks = TaskStorage.loadAll();

  if (tasks.isEmpty) {
    stdout.writeln('No tasks yet. Add one with: dart_todo add &lt;title&gt;');
    return;
  }

  stdout.writeln('');
  stdout.writeln('  ID   Status      Priority   Title');
  stdout.writeln('  ───  ──────────  ─────────  ────────────────────────');

  for (final task in tasks) {
    final status = task.isComplete ? 'done  ' : 'pending';
    final id = task.id.toString().padRight(4);
    final priority = task.priority.padRight(9);
    stdout.writeln('  \(id \)status  \(priority  \){task.title}');
  }

  stdout.writeln('');
}
</code></pre>
<p><code>lib/commands/complete_command.dart</code>:</p>
<pre><code class="language-dart">import 'dart:io';

import '../storage/task_storage.dart';

void runComplete(List&lt;String&gt; args) {
  if (args.isEmpty) {
    stderr.writeln('Usage: dart_todo complete &lt;id&gt;');
    exit(2);
  }

  final id = int.tryParse(args[0]);
  if (id == null) {
    stderr.writeln('Error: "${args[0]}" is not a valid task ID');
    exit(1);
  }

  final tasks = TaskStorage.loadAll();
  final index = tasks.indexWhere((t) =&gt; t.id == id);

  if (index == -1) {
    stderr.writeln('Error: No task found with ID $id');
    exit(1);
  }

  if (tasks[index].isComplete) {
    stdout.writeln('Task #$id is already complete.');
    return;
  }

  tasks[index] = tasks[index].copyWith(isComplete: true);
  TaskStorage.saveAll(tasks);

  stdout.writeln('Task #\(id marked as complete: "\){tasks[index].title}"');
}
</code></pre>
<p><code>lib/commands/delete_command.dart</code>:</p>
<pre><code class="language-dart">import 'dart:io';

import '../storage/task_storage.dart';

void runDelete(List&lt;String&gt; args) {
  if (args.isEmpty) {
    stderr.writeln('Usage: dart_todo delete &lt;id&gt;');
    exit(2);
  }

  final id = int.tryParse(args[0]);
  if (id == null) {
    stderr.writeln('Error: "${args[0]}" is not a valid task ID');
    exit(1);
  }

  final tasks = TaskStorage.loadAll();
  final index = tasks.indexWhere((t) =&gt; t.id == id);

  if (index == -1) {
    stderr.writeln('Error: No task found with ID $id');
    exit(1);
  }

  final title = tasks[index].title;
  tasks.removeAt(index);
  TaskStorage.saveAll(tasks);

  stdout.writeln('Deleted task #\(id: "\)title"');
}
</code></pre>
<p><code>lib/commands/clear_command.dart</code>:</p>
<pre><code class="language-dart">import 'dart:io';

import '../storage/task_storage.dart';

void runClear() {
  stdout.write('Are you sure you want to delete all tasks? (y/N): ');
  final input = stdin.readLineSync()?.trim().toLowerCase();

  if (input != 'y') {
    stdout.writeln('Cancelled.');
    return;
  }

  TaskStorage.saveAll([]);
  stdout.writeln('All tasks cleared.');
}
</code></pre>
<h4 id="heading-step-4-entry-point-bindarttododart">Step 4 — Entry Point (<code>bin/dart_todo.dart</code>)</h4>
<pre><code class="language-dart">import 'dart:io';

import 'package:args/args.dart';

import '../lib/commands/add_command.dart';
import '../lib/commands/clear_command.dart';
import '../lib/commands/complete_command.dart';
import '../lib/commands/delete_command.dart';
import '../lib/commands/list_command.dart';

void main(List&lt;String&gt; arguments) {
  final parser = ArgParser();

  // Add subcommand parsers
  final addParser = ArgParser()
    ..addOption(
      'priority',
      abbr: 'p',
      defaultsTo: 'normal',
      allowed: ['high', 'normal', 'low'],
      help: 'Task priority level',
    );

  parser
    ..addCommand('add', addParser)
    ..addCommand('list')
    ..addCommand('complete')
    ..addCommand('delete')
    ..addCommand('clear')
    ..addFlag('help', abbr: 'h', negatable: false, help: 'Show help');

  ArgResults results;

  try {
    results = parser.parse(arguments);
  } catch (e) {
    stderr.writeln('Error: $e');
    stderr.writeln(parser.usage);
    exit(2);
  }

  if (results['help'] as bool || results.command == null) {
    printHelp(parser);
    exit(0);
  }

  final command = results.command!;

  switch (command.name) {
    case 'add':
      runAdd(command.rest, command['priority'] as String);
    case 'list':
      runList();
    case 'complete':
      runComplete(command.rest);
    case 'delete':
      runDelete(command.rest);
    case 'clear':
      runClear();
    default:
      stderr.writeln('Unknown command: "${command.name}"');
      exit(1);
  }
}

void printHelp(ArgParser parser) {
  stdout.writeln('''
dart_todo — a terminal task manager

Usage:
  dart_todo &lt;command&gt; [arguments]

Commands:
  add &lt;title&gt;        Add a new task
    -p, --priority   Priority: high, normal, low (default: normal)
  list               List all tasks
  complete &lt;id&gt;      Mark a task as complete
  delete &lt;id&gt;        Delete a task
  clear              Delete all tasks

Examples:
  dart_todo add "Write the CLI article" --priority=high
  dart_todo list
  dart_todo complete 1
  dart_todo delete 2
  dart_todo clear
  ''');
}
</code></pre>
<p>Run it:</p>
<pre><code class="language-bash">dart run bin/dart_todo.dart add "Write the CLI article" --priority=high
# Added task #1: "Write the CLI article" [high]

dart run bin/dart_todo.dart add "Review PR comments"
# Added task #2: "Review PR comments" [normal]

dart run bin/dart_todo.dart list
#   ID   Status      Priority   Title
#   ───  ──────────  ─────────  ────────────────────────
#   1    ⬜ pending  high       Write the CLI article
#   2    ⬜ pending  normal     Review PR comments

dart run bin/dart_todo.dart complete 1
# Task #1 marked as complete: "Write the CLI article"

dart run bin/dart_todo.dart delete 2
# Deleted task #2: "Review PR comments"
</code></pre>
<p><code>dart_todo</code> demonstrates the patterns that form the backbone of almost every real CLI tool — argument parsing with <code>args</code>, JSON persistence, interactive prompts, structured output, and clean error handling across every command.</p>
<h2 id="heading-cli-3-darthttp-a-lightweight-api-request-runner">CLI 3 — dart_http: A Lightweight API Request Runner</h2>
<p>This is the most complex CLI in this article – and the most immediately useful. <code>dart_http</code> lets developers make HTTP requests directly from the terminal, with pretty-printed JSON responses, response metadata, header support, and the ability to save responses to a file.</p>
<pre><code class="language-bash">dart_http get https://jsonplaceholder.typicode.com/users/1
dart_http post https://jsonplaceholder.typicode.com/posts --body='{"title":"Hello"}'
dart_http get https://jsonplaceholder.typicode.com/users --save=users.json
dart_http get https://api.example.com/me --header="Authorization: Bearer mytoken"
</code></pre>
<h3 id="heading-building-darthttp">Building dart_http</h3>
<p>Create the project:</p>
<pre><code class="language-bash">dart create -t console dart_http
cd dart_http
</code></pre>
<p>Update <code>pubspec.yaml</code>:</p>
<pre><code class="language-yaml">name: dart_http
description: A lightweight API request runner for the terminal
version: 1.0.0

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

executables:
  dart_http: dart_http

dependencies:
  args: ^2.4.2
  http: ^1.2.1

dev_dependencies:
  lints: ^3.0.0
  test: ^1.24.0
</code></pre>
<p>Run <code>dart pub get</code>.</p>
<p>Project structure:</p>
<pre><code class="language-plaintext">dart_http/
  bin/
    dart_http.dart
  lib/
    runner/
      request_runner.dart
    printer/
      response_printer.dart
    utils/
      headers_parser.dart
  pubspec.yaml
</code></pre>
<h4 id="heading-step-1-headers-parser-libutilsheadersparserdart">Step 1 — Headers Parser (<code>lib/utils/headers_parser.dart</code>)</h4>
<pre><code class="language-dart">Map&lt;String, String&gt; parseHeaders(List&lt;String&gt; rawHeaders) {
  final headers = &lt;String, String&gt;{};

  for (final header in rawHeaders) {
    final index = header.indexOf(':');
    if (index == -1) continue;

    final key = header.substring(0, index).trim();
    final value = header.substring(index + 1).trim();
    headers[key] = value;
  }

  return headers;
}
</code></pre>
<h4 id="heading-step-2-response-printer-libprinterresponseprinterdart">Step 2 — Response Printer (<code>lib/printer/response_printer.dart</code>)</h4>
<pre><code class="language-dart">import 'dart:convert';
import 'dart:io';

void printResponse({
  required int statusCode,
  required String body,
  required int durationMs,
  required int bodyBytes,
}) {
  final statusLabel = _statusLabel(statusCode);
  final size = _formatSize(bodyBytes);

  stdout.writeln('');
  stdout.writeln('\(statusLabel | \){durationMs}ms | $size');
  stdout.writeln('─' * 50);

  try {
    final decoded = jsonDecode(body);
    const encoder = JsonEncoder.withIndent('  ');
    stdout.writeln(encoder.convert(decoded));
  } catch (_) {
    // Not JSON — print as plain text
    stdout.writeln(body);
  }

  stdout.writeln('');
}

String _statusLabel(int code) {
  if (code &gt;= 200 &amp;&amp; code &lt; 300) return '✅ $code';
  if (code &gt;= 300 &amp;&amp; code &lt; 400) return '↪️  $code';
  if (code &gt;= 400 &amp;&amp; code &lt; 500) return '❌ $code';
  return '$code';
}

String _formatSize(int bytes) {
  if (bytes &lt; 1024) return '${bytes}b';
  if (bytes &lt; 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)}kb';
  return '${(bytes / (1024 * 1024)).toStringAsFixed(1)}mb';
}
</code></pre>
<h4 id="heading-step-3-request-runner-librunnerrequestrunnerdart">Step 3 — Request Runner (<code>lib/runner/request_runner.dart</code>)</h4>
<pre><code class="language-dart">import 'dart:io';

import 'package:http/http.dart' as http;

import '../printer/response_printer.dart';

Future&lt;void&gt; runRequest({
  required String method,
  required String url,
  required Map&lt;String, String&gt; headers,
  String? body,
  String? saveToFile,
}) async {
  final uri = Uri.tryParse(url);

  if (uri == null) {
    stderr.writeln('Error: "$url" is not a valid URL');
    exit(1);
  }

  stdout.writeln('→ \({method.toUpperCase()} \)url');

  http.Response response;
  final stopwatch = Stopwatch()..start();

  try {
    switch (method.toLowerCase()) {
      case 'get':
        response = await http.get(uri, headers: headers);
      case 'post':
        response = await http.post(uri, headers: headers, body: body);
      case 'put':
        response = await http.put(uri, headers: headers, body: body);
      case 'patch':
        response = await http.patch(uri, headers: headers, body: body);
      case 'delete':
        response = await http.delete(uri, headers: headers);
      default:
        stderr.writeln('Error: unsupported method "$method"');
        exit(2);
    }
  } catch (e) {
    stderr.writeln('Error: request failed — $e');
    exit(1);
  }

  stopwatch.stop();

  printResponse(
    statusCode: response.statusCode,
    body: response.body,
    durationMs: stopwatch.elapsedMilliseconds,
    bodyBytes: response.bodyBytes.length,
  );

  if (saveToFile != null) {
    final file = File(saveToFile);
    file.writeAsStringSync(response.body);
    stdout.writeln('Response saved to $saveToFile');
  }
}
</code></pre>
<h4 id="heading-step-4-entry-point-bindarthttpdart">Step 4 — Entry Point (<code>bin/dart_http.dart</code>)</h4>
<pre><code class="language-dart">import 'dart:io';

import 'package:args/args.dart';

import '../lib/runner/request_runner.dart';
import '../lib/utils/headers_parser.dart';

void main(List&lt;String&gt; arguments) async {
  final parser = ArgParser();

  for (final method in ['get', 'post', 'put', 'patch', 'delete']) {
    final commandParser = ArgParser()
      ..addMultiOption('header', abbr: 'H', help: 'Request header (repeatable)')
      ..addOption('body', abbr: 'b', help: 'Request body (for POST/PUT/PATCH)')
      ..addOption('save', abbr: 's', help: 'Save response body to a file');

    parser.addCommand(method, commandParser);
  }

  parser.addFlag('help', abbr: 'h', negatable: false, help: 'Show help');

  ArgResults results;

  try {
    results = parser.parse(arguments);
  } catch (e) {
    stderr.writeln('Error: $e');
    printHelp();
    exit(2);
  }

  if (results['help'] as bool || results.command == null) {
    printHelp();
    exit(0);
  }

  final command = results.command!;
  final method = command.name!;
  final rest = command.rest;

  if (rest.isEmpty) {
    stderr.writeln('Error: please provide a URL');
    stderr.writeln('Usage: dart_http $method &lt;url&gt;');
    exit(2);
  }

  final url = rest[0];
  final rawHeaders = command['header'] as List&lt;String&gt;;
  final body = command['body'] as String?;
  final saveToFile = command['save'] as String?;

  final headers = parseHeaders(rawHeaders);

  // Default Content-Type for requests with a body
  if (body != null &amp;&amp; !headers.containsKey('Content-Type')) {
    headers['Content-Type'] = 'application/json';
  }

  await runRequest(
    method: method,
    url: url,
    headers: headers,
    body: body,
    saveToFile: saveToFile,
  );
}

void printHelp() {
  stdout.writeln('''
dart_http — a lightweight API request runner

Usage:
  dart_http &lt;method&gt; &lt;url&gt; [options]

Methods:
  get       Send a GET request
  post      Send a POST request
  put       Send a PUT request
  patch     Send a PATCH request
  delete    Send a DELETE request

Options:
  -H, --header    Add a request header (repeatable)
  -b, --body      Request body (JSON string)
  -s, --save      Save response body to a file
  -h, --help      Show this help message

Examples:
  dart_http get https://jsonplaceholder.typicode.com/users
  dart_http get https://api.example.com/me --header="Authorization: Bearer token"
  dart_http post https://api.example.com/posts --body=\'{"title":"Hello"}\'
  dart_http get https://api.example.com/users --save=users.json
  ''');
}
</code></pre>
<p>Run it:</p>
<pre><code class="language-bash">dart run bin/dart_http.dart get https://jsonplaceholder.typicode.com/users/1

# → GET https://jsonplaceholder.typicode.com/users/1
# 200 | 87ms | 510b
# ──────────────────────────────────────────────────
# {
#   "id": 1,
#   "name": "Leanne Graham",
#   "username": "Bret",
#   "email": "Sincere@april.biz"
# }

dart run bin/dart_http.dart get https://jsonplaceholder.typicode.com/users --save=users.json
# → GET https://jsonplaceholder.typicode.com/users
# 200 | 143ms | 5.3kb
# ──────────────────────────────────────────────────
# [ ... ]
# Response saved to users.json

dart run bin/dart_http.dart post https://jsonplaceholder.typicode.com/posts \
  --body='{"title":"Hello from dart_http","userId":1}'
# → POST https://jsonplaceholder.typicode.com/posts
# 201 | 312ms | 72b
</code></pre>
<h2 id="heading-adding-color-and-polish-to-your-cli">Adding Color and Polish to Your CLI</h2>
<p>The CLIs above are functional, but terminal output can be made significantly more readable with color. The <code>ansi_styles</code> package provides ANSI escape code support for coloring text in the terminal.</p>
<p>Add it to <code>pubspec.yaml</code>:</p>
<pre><code class="language-yaml">dependencies:
  ansi_styles: ^0.3.0
</code></pre>
<p>Using it:</p>
<pre><code class="language-dart">import 'package:ansi_styles/ansi_styles.dart';

stdout.writeln(AnsiStyles.green('✅ Success'));
stdout.writeln(AnsiStyles.red('❌ Error: something went wrong'));
stdout.writeln(AnsiStyles.yellow('⚠️  Warning: check your config'));
stdout.writeln(AnsiStyles.bold('dart_http — API request runner'));
stdout.writeln(AnsiStyles.cyan('→ GET https://api.example.com/users'));
</code></pre>
<p>Apply color intentionally and consistently:</p>
<ul>
<li><p><strong>Green</strong> — success states, completed operations</p>
</li>
<li><p><strong>Red</strong> — errors and failures</p>
</li>
<li><p><strong>Yellow</strong> — warnings and non-blocking issues</p>
</li>
<li><p><strong>Cyan</strong> — informational output, URLs, paths</p>
</li>
<li><p><strong>Bold</strong> — headers, tool names, important values</p>
</li>
</ul>
<p>Avoid coloring everything. Color loses meaning when it is everywhere. Use it to draw the user's eye to what actually matters.</p>
<h2 id="heading-testing-your-cli-tool">Testing Your CLI Tool</h2>
<p>CLI tools are testable, and they should be tested. The most reliable approach is to test the logic inside your commands directly — not the terminal output formatting, but the behaviour.</p>
<p>Add <code>test</code> to your dev dependencies if it's not already there:</p>
<pre><code class="language-yaml">dev_dependencies:
  test: ^1.24.0
</code></pre>
<p><strong>Testing command logic:</strong></p>
<pre><code class="language-dart">import 'package:test/test.dart';

import '../lib/models/task.dart';

void main() {
  group('Task model', () {
    test('copyWith updates isComplete correctly', () {
      final task = Task(
        id: 1,
        title: 'Write tests',
        priority: 'high',
        createdAt: DateTime.now(),
      );

      final completed = task.copyWith(isComplete: true);

      expect(completed.isComplete, isTrue);
      expect(completed.title, equals('Write tests'));
      expect(completed.id, equals(1));
    });

    test('toJson and fromJson round-trips correctly', () {
      final task = Task(
        id: 2,
        title: 'Ship the tool',
        priority: 'normal',
        createdAt: DateTime.parse('2025-01-01T00:00:00.000'),
      );

      final json = task.toJson();
      final restored = Task.fromJson(json);

      expect(restored.id, equals(task.id));
      expect(restored.title, equals(task.title));
      expect(restored.priority, equals(task.priority));
    });
  });
}
</code></pre>
<p><strong>Testing the headers parser:</strong></p>
<pre><code class="language-dart">import 'package:test/test.dart';

import '../lib/utils/headers_parser.dart';

void main() {
  group('parseHeaders', () {
    test('parses a single header correctly', () {
      final result = parseHeaders(['Authorization: Bearer mytoken']);
      expect(result['Authorization'], equals('Bearer mytoken'));
    });

    test('parses multiple headers', () {
      final result = parseHeaders([
        'Authorization: Bearer token',
        'Accept: application/json',
      ]);
      expect(result.length, equals(2));
      expect(result['Accept'], equals('application/json'));
    });

    test('ignores malformed headers without a colon', () {
      final result = parseHeaders(['malformed-header']);
      expect(result.isEmpty, isTrue);
    });
  });
}
</code></pre>
<p>Run your tests:</p>
<pre><code class="language-bash">dart test
</code></pre>
<h2 id="heading-deploying-and-distributing-your-cli">Deploying and Distributing Your CLI</h2>
<p>Building a CLI tool is half the work. Getting it into the hands of developers is the other half. There are five distribution paths available, each suited to a different use case.</p>
<h3 id="heading-mode-1-pubdev-public-package-distribution">Mode 1: pub.dev — Public Package Distribution</h3>
<p>Publishing to pub.dev makes your tool installable by anyone in the Dart and Flutter community with a single command.</p>
<h4 id="heading-prepare-your-package">Prepare your package:</h4>
<p>Your <code>pubspec.yaml</code> needs to be complete:</p>
<pre><code class="language-yaml">name: dart_http
description: A lightweight API request runner for Dart developers.
version: 1.0.0
homepage: https://github.com/yourname/dart_http

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

executables:
  dart_http: dart_http
</code></pre>
<p>The <code>executables</code> block is critical. It tells pub.dev which script in <code>bin/</code> to expose as a runnable command.</p>
<p>You also need:</p>
<ul>
<li><p><code>README.md</code> — what the tool does, how to install it, usage examples</p>
</li>
<li><p><code>CHANGELOG.md</code> — version history</p>
</li>
<li><p><code>LICENSE</code> — an open source license (MIT is standard)</p>
</li>
</ul>
<h4 id="heading-validate-before-publishing">Validate before publishing:</h4>
<pre><code class="language-bash">dart pub publish --dry-run
</code></pre>
<p>This runs all validation checks without actually publishing. Fix any warnings before proceeding.</p>
<h4 id="heading-publish">Publish:</h4>
<pre><code class="language-bash">dart pub publish
</code></pre>
<p>You will be prompted to authenticate with your pub.dev account. Once published, your tool is available globally:</p>
<pre><code class="language-bash">dart pub global activate dart_http
dart_http get https://api.example.com/users
</code></pre>
<h3 id="heading-mode-2-local-path-activation">Mode 2: Local Path Activation</h3>
<p>For internal team tools that you don't want to publish publicly, activate directly from a local or cloned repository:</p>
<pre><code class="language-bash">dart pub global activate --source path /path/to/dart_http
</code></pre>
<p>Any developer on the team clones the repo and runs this command once. The tool is then available globally in their terminal without needing a pub.dev publish.</p>
<p>This is the right distribution mode for:</p>
<ul>
<li><p>Internal company tooling</p>
</li>
<li><p>Tools that depend on private packages</p>
</li>
<li><p>Work-in-progress tools shared within a team before a public release</p>
</li>
</ul>
<h3 id="heading-mode-3-compiled-binary-via-github-releases">Mode 3: Compiled Binary via GitHub Releases</h3>
<p>Dart can compile to a self-contained native executable — no Dart SDK required on the target machine. This makes your tool accessible to developers outside the Dart ecosystem.</p>
<h4 id="heading-compile">Compile:</h4>
<pre><code class="language-bash"># macOS
dart compile exe bin/dart_http.dart -o dist/dart_http-macos

# Linux
dart compile exe bin/dart_http.dart -o dist/dart_http-linux

# Windows
dart compile exe bin/dart_http.dart -o dist/dart_http-windows.exe
</code></pre>
<p>The compiled binary is fully self-contained. Copy it to any machine and run it — no Dart installation needed.</p>
<h4 id="heading-automate-with-github-actions">Automate with GitHub Actions:</h4>
<p>Create <code>.github/workflows/release.yml</code>:</p>
<pre><code class="language-yaml">name: Release

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest, windows-latest]
    runs-on: ${{ matrix.os }}

    steps:
      - uses: actions/checkout@v3

      - uses: dart-lang/setup-dart@v1
        with:
          sdk: stable

      - name: Install dependencies
        run: dart pub get

      - name: Compile binary
        run: |
          mkdir -p dist
          dart compile exe bin/dart_http.dart -o dist/dart_http-${{ runner.os }}

      - name: Upload binary to release
        uses: softprops/action-gh-release@v1
        with:
          files: dist/dart_http-${{ runner.os }}
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
</code></pre>
<p>Every time you push a version tag (<code>v1.0.0</code>), GitHub Actions compiles binaries for all three platforms and attaches them to the GitHub Release automatically.</p>
<h4 id="heading-write-an-install-script">Write an install script:</h4>
<pre><code class="language-bash">#!/usr/bin/env bash
set -euo pipefail

VERSION="1.0.0"
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
BINARY="dart_http-$OS"
INSTALL_DIR="/usr/local/bin"

curl -L "https://github.com/yourname/dart_http/releases/download/v\(VERSION/\)BINARY" \
  -o "$INSTALL_DIR/dart_http"

chmod +x "$INSTALL_DIR/dart_http"
echo "dart_http installed successfully"
</code></pre>
<p>Developers install it with:</p>
<pre><code class="language-bash">curl -fsSL https://raw.githubusercontent.com/yourname/dart_http/main/install.sh | bash
</code></pre>
<h3 id="heading-mode-4-homebrew-tap">Mode 4: Homebrew Tap</h3>
<p>Homebrew is the standard package manager for macOS and is widely used on Linux. A Homebrew tap makes your tool installable with <code>brew install</code> — the most familiar installation pattern for macOS developers.</p>
<h4 id="heading-create-your-tap-repository">Create your tap repository:</h4>
<p>Create a new GitHub repository named <code>homebrew-tools</code> (the <code>homebrew-</code> prefix is required by Homebrew's naming convention).</p>
<h4 id="heading-write-the-formula">Write the formula:</h4>
<p>Create <code>Formula/dart_http.rb</code> in that repository:</p>
<pre><code class="language-ruby">class DartHttp &lt; Formula
  desc "A lightweight API request runner for the terminal"
  homepage "https://github.com/yourname/dart_http"
  version "1.0.0"

  on_macos do
    url "https://github.com/yourname/dart_http/releases/download/v1.0.0/dart_http-macOS"
    sha256 "YOUR_SHA256_HASH_HERE"
  end

  on_linux do
    url "https://github.com/yourname/dart_http/releases/download/v1.0.0/dart_http-Linux"
    sha256 "YOUR_SHA256_HASH_HERE"
  end

  def install
    bin.install "dart_http-#{OS.mac? ? 'macOS' : 'Linux'}" =&gt; "dart_http"
  end

  test do
    system "#{bin}/dart_http", "--help"
  end
end
</code></pre>
<p>Generate the SHA256 hash for each binary:</p>
<pre><code class="language-bash">shasum -a 256 dist/dart_http-macOS
</code></pre>
<h4 id="heading-install-from-the-tap">Install from the tap:</h4>
<pre><code class="language-bash">brew tap yourname/tools
brew install dart_http
</code></pre>
<p>When you release a new version, update the <code>url</code> and <code>sha256</code> values in the formula and push the change. Users run <code>brew upgrade dart_http</code> to update.</p>
<h3 id="heading-mode-5-docker">Mode 5: Docker</h3>
<p>Docker distribution is best suited for CI environments, teams that standardise on containers, or tools with complex dependencies.</p>
<h4 id="heading-write-a-dockerfile">Write a Dockerfile:</h4>
<pre><code class="language-dockerfile">FROM dart:stable AS build

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

COPY . .
RUN dart compile exe bin/dart_http.dart -o /app/dart_http

FROM debian:stable-slim
COPY --from=build /app/dart_http /usr/local/bin/dart_http

ENTRYPOINT ["dart_http"]
</code></pre>
<p>This uses a multi-stage build: the first stage compiles the binary using the Dart SDK image, and the second stage copies only the binary into a minimal Debian image. The final image has no Dart SDK — just the compiled binary.</p>
<h4 id="heading-build-and-run">Build and run:</h4>
<pre><code class="language-bash">docker build -t dart_http .
docker run dart_http get https://jsonplaceholder.typicode.com/users/1
</code></pre>
<h4 id="heading-publish-to-docker-hub">Publish to Docker Hub:</h4>
<pre><code class="language-bash">docker tag dart_http yourname/dart_http:1.0.0
docker push yourname/dart_http:1.0.0
</code></pre>
<p>Users can then run your tool without installing anything locally:</p>
<pre><code class="language-bash">docker run yourname/dart_http get https://api.example.com/users
</code></pre>
<h2 id="heading-choosing-the-right-distribution-mode">Choosing the Right Distribution Mode</h2>
<table>
<thead>
<tr>
<th>Mode</th>
<th>Best for</th>
<th>Dart SDK required</th>
</tr>
</thead>
<tbody><tr>
<td>pub.dev</td>
<td>Public Dart/Flutter developer tools</td>
<td>Yes</td>
</tr>
<tr>
<td>Local path activation</td>
<td>Internal team tools, pre-release builds</td>
<td>Yes</td>
</tr>
<tr>
<td>Compiled binary</td>
<td>Language-agnostic tools, broad adoption</td>
<td>No</td>
</tr>
<tr>
<td>Homebrew tap</td>
<td>macOS/Linux developer tools</td>
<td>No</td>
</tr>
<tr>
<td>Docker</td>
<td>CI environments, complex dependencies</td>
<td>No</td>
</tr>
</tbody></table>
<p>For most tools, the practical recommendation is:</p>
<ul>
<li><p>Start with <strong>pub.dev</strong> if your audience is Dart developers</p>
</li>
<li><p>Add <strong>compiled binary + GitHub Releases</strong> once you want broader adoption</p>
</li>
<li><p>Add a <strong>Homebrew tap</strong> when macOS developers start asking for it</p>
</li>
<li><p>Use <strong>Docker</strong> only when it is already part of your team's workflow</p>
</li>
</ul>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You've gone from understanding what a CLI is to building three progressively complex tools and distributing them across five different channels.</p>
<p>The foundational skills – <code>args</code>, <code>stdin</code>, <code>stdout</code>, <code>stderr</code>, exit codes, file I/O, and process spawning – are the same building blocks that tools like <code>flutter</code>, <code>git</code>, and <code>dart</code> themselves are built on. Everything else is composition.</p>
<p>The three CLIs we built (Hello CLI, <code>dart_todo</code>, and <code>dart_http</code>) each introduced a new layer: raw Dart fundamentals, the <code>args</code> package with JSON persistence, and real-world HTTP interaction. The distribution section ensures that whatever you build next, you have a clear path to getting it in front of the developers who will use it.</p>
<p>Dart is a powerful language for CLI development. Its strong typing, async support, native compilation, and pub.dev ecosystem make it a serious choice for building developer tooling, not just mobile apps.</p>
<p>The next step is building something that solves a real problem for you or your team, and shipping it.</p>
<p>Happy coding!!</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Complete Flutter CI/CD Pipeline with Codemagic: From PR Quality Gates to Automated Store Releases ]]>
                </title>
                <description>
                    <![CDATA[ If you've spent any time shipping Flutter apps manually, you already know the drill. Someone on the team finishes a feature, builds the APK locally, signs it (hopefully with the right keystore), uploa ]]>
                </description>
                <link>https://www.freecodecamp.org/news/build-a-complete-flutter-ci-cd-pipeline-with-codemagic/</link>
                <guid isPermaLink="false">69c1dcba30a9b81e3ac436d8</guid>
                
                    <category>
                        <![CDATA[ code magic ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mobile app ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ code ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Tue, 24 Mar 2026 00:37:14 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/914de6f3-5b7f-48ff-a092-1f8d095202e5.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>If you've spent any time shipping Flutter apps manually, you already know the drill. Someone on the team finishes a feature, builds the APK locally, signs it (hopefully with the right keystore), uploads it somewhere, and notifies the QA team. Repeat for iOS. Repeat for staging. Repeat for production.</p>
<p>And somewhere in that chain, something often goes wrong: an incorrect API key, a missed signing step, a build that worked on one machine and failed on another.</p>
<p>The solution is a properly configured CI/CD pipeline that takes that entire chain out of human hands. And in this article, we're building exactly that using Codemagic.</p>
<h2 id="heading-what-is-codemagic">What is Codemagic?</h2>
<p>Codemagic is a dedicated CI/CD platform built from the ground up specifically for mobile applications.</p>
<p>Unlike general-purpose CI platforms, Codemagic understands Flutter natively. It ships with Flutter pre-installed on its build machines, has dedicated support for Apple code signing, and integrates directly with both the Google Play Store and App Store Connect. This means less configuration noise and more focus on what actually matters , which is your deployment logic.</p>
<p>The pipeline we'll be building covers three distinct stages across both Android and iOS:</p>
<ul>
<li><p>A pull request gate that blocks unverified code from reaching your base branch</p>
</li>
<li><p>A staging pipeline that injects real environment config, builds signed artifacts, and ships them to testers via Firebase App Distribution and TestFlight</p>
</li>
<li><p>A production pipeline that obfuscates builds, uploads crash symbols to Sentry, and submits directly to the Play Store and App Store Connect</p>
</li>
</ul>
<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-understanding-codemagics-yaml-approach">Understanding Codemagic's YAML Approach</a></p>
</li>
<li><p><a href="#heading-pipeline-architecture">Pipeline Architecture</a></p>
</li>
<li><p><a href="#heading-the-helper-scripts">The Helper Scripts</a></p>
</li>
<li><p><a href="#heading-pr-quality-gate">PR Quality Gate</a></p>
</li>
<li><p><a href="#heading-android-pipeline">Android Pipeline</a></p>
</li>
<li><p><a href="#heading-ios-pipeline">iOS Pipeline</a></p>
</li>
<li><p><a href="#heading-environment-variables-and-secrets-reference">Environment Variables and Secrets Reference</a></p>
</li>
<li><p><a href="#heading-end-to-end-flow">End-to-End Flow</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ul>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>You'll need the following before starting:</p>
<ul>
<li><p>A Flutter app with functional Android and iOS builds</p>
</li>
<li><p>A Codemagic account with your repository connected</p>
</li>
<li><p>A Firebase project with App Distribution set up</p>
</li>
<li><p>A Sentry project configured for your app</p>
</li>
<li><p>A Google Play Console app with at least an internal track ready</p>
</li>
<li><p>An Apple Developer account with App Store Connect access</p>
</li>
<li><p>A Google Play service account with the necessary API permissions</p>
</li>
<li><p>Familiarity with writing Bash scripts</p>
</li>
</ul>
<h2 id="heading-understanding-codemagics-yaml-approach">Understanding Codemagic's YAML Approach</h2>
<p>Codemagic offers a visual workflow editor for teams that prefer a GUI – but we're not using that here. The <code>codemagic.yaml</code> approach gives you version-controlled, reviewable, fully reproducible pipeline definitions that live right alongside your application code. Any change to the pipeline goes through the same PR process as any other change. That matters in a team environment.</p>
<p>The file lives at the root of your project:</p>
<pre><code class="language-plaintext">your-flutter-app/
  codemagic.yaml     
  lib/
  android/
  ios/
  scripts/
</code></pre>
<p>Codemagic detects this file when a build is triggered and executes the appropriate workflow based on the rules you define. One file, multiple workflows, all environments – no duplication.</p>
<h2 id="heading-pipeline-architecture">Pipeline Architecture</h2>
<p>Before writing any YAML, it helps to define exactly what the pipeline needs to do. The use case here is a team with three protected branches: <code>develop</code>, <code>staging</code>, and <code>production</code>. Each branch represents a distinct stage in the release lifecycle, and the pipeline behaves differently depending on which branch triggered it.</p>
<p>Here is how the three environments map to pipeline behaviour:</p>
<p><strong>PR into develop</strong>: When a developer raises a pull request targeting the <code>develop</code> branch, a quality gate workflow fires. It runs code formatting checks, static analysis, the full test suite, and enforces a minimum coverage threshold. The PR cannot be considered clean until all of these pass.</p>
<p><strong>Push to develop or staging</strong>: When code lands on either of these branches, the platform-specific build pipelines trigger. They detect the target branch, inject the correct environment configuration (dev or staging API keys), build signed artifacts, and distribute them to the appropriate testing channels: Firebase App Distribution for Android, TestFlight for iOS.</p>
<p><strong>Push to production</strong>: When code reaches the production branch, the pipelines switch into release mode. Builds are obfuscated, debug symbols are uploaded to Sentry for crash observability, and the final artifacts are submitted directly to the Play Store and App Store Connect.</p>
<p>Your project structure will look like this:</p>
<pre><code class="language-plaintext">codemagic.yaml

scripts/
  generate_config.sh
  quality_checks.sh
  upload_symbols.sh

lib/
  core/
    env/
      env_ci.dart       
      env_ci.g.dart     
</code></pre>
<h2 id="heading-the-helper-scripts">The Helper Scripts</h2>
<p>Rather than cramming logic directly into YAML, this pipeline delegates its core operations to three Bash scripts that live in a <code>scripts/</code> folder at the project root. This keeps the YAML readable and, crucially, means you can run the exact same logic on your local machine that CI runs – eliminating an entire class of "works on my machine" issues.</p>
<p>Make all three scripts executable before committing them:</p>
<pre><code class="language-bash">chmod +x scripts/generate_config.sh
chmod +x scripts/quality_checks.sh
chmod +x scripts/upload_symbols.sh
</code></pre>
<h3 id="heading-generateconfigsh">generate_config.sh</h3>
<p>Injecting secrets safely is one of the hardest CI/CD problems in mobile development. The strategy here avoids committing credentials entirely: a Dart file with placeholder values is committed to source control, and at build time the script replaces those placeholders with real values sourced from Codemagic's encrypted secret storage.</p>
<pre><code class="language-bash">#!/usr/bin/env bash
set -euo pipefail

# Usage: ./scripts/generate_config.sh ENV_NAME BASE_URL ENCRYPTION_KEY
ENV_NAME=${1:-}
BASE_URL=${2:-}
ENCRYPTION_KEY=${3:-}

TEMPLATE="lib/core/env/env_ci.dart"
OUT="lib/core/env/env_ci.g.dart"

if [ -z "\(ENV_NAME" ] || [ -z "\)BASE_URL" ] || [ -z "$ENCRYPTION_KEY" ]; then
  echo "Usage: $0 &lt;env-name&gt; &lt;base-url&gt; &lt;encryption-key&gt;"
  exit 2
fi

sed -e "s|&lt;&lt;BASE_URL&gt;&gt;|$BASE_URL|g" \
    -e "s|&lt;&lt;ENCRYPTION_KEY&gt;&gt;|$ENCRYPTION_KEY|g" \
    -e "s|&lt;&lt;ENV_NAME&gt;&gt;|$ENV_NAME|g" \
    "\(TEMPLATE" &gt; "\)OUT"

echo "✅ Generated config for $ENV_NAME"
</code></pre>
<p><strong>How it works:</strong></p>
<p><code>set -euo pipefail</code> enforces strict failure behaviour. <code>-e</code> exits immediately on any failed command, <code>-u</code> exits on undefined variables, and <code>-o pipefail</code> catches failures anywhere in a pipeline – not just the last command. In CI, silent failures can produce broken builds that look like they succeeded. This line prevents that.</p>
<p>The script takes three positional arguments: the environment name (<code>dev</code>, <code>staging</code>, or <code>production</code>), the API base URL, and an encryption or API key. The <code>${1:-}</code> syntax defaults to an empty string if an argument is missing, which the validation block then catches explicitly with a clear usage message and an exit code of <code>2</code> (the conventional code for incorrect usage).</p>
<p>At the heart of the script, <code>sed</code> performs three placeholder replacements in a single pass over the template file, writing the result to <code>env_ci.g.dart</code>. That generated file must be added to <code>.gitignore</code>. It only ever exists inside a running build or on a developer's local machine after they run the script manually.</p>
<p>The two Dart files involved have completely different roles:</p>
<p><code>env_ci.dart</code> – committed to source control, contains only placeholders:</p>
<pre><code class="language-dart">// lib/core/env/env_ci.dart
class EnvConfig {
  static const String baseUrl = '&lt;&lt;BASE_URL&gt;&gt;';
  static const String encryptionKey = '&lt;&lt;ENCRYPTION_KEY&gt;&gt;';
  static const String environment = '&lt;&lt;ENV_NAME&gt;&gt;';
}
</code></pre>
<p><code>env_ci.g.dart</code> – generated at build time, contains real values, never committed:</p>
<pre><code class="language-dart">// lib/core/env/env_ci.g.dart
// GENERATED FILE — DO NOT COMMIT
class EnvConfig {
  static const String baseUrl = 'https://staging.api.example.com';
  static const String encryptionKey = 'sk_live_xxxxx';
  static const String environment = 'staging';
}
</code></pre>
<p>Add the generated file to <code>.gitignore</code>:</p>
<pre><code class="language-plaintext"># Generated environment config
lib/core/env/env_ci.g.dart
</code></pre>
<h3 id="heading-qualitycheckssh">quality_checks.sh</h3>
<p>This script defines what passing quality means for your codebase. Every check it runs is a gate: if any step fails, the script stops immediately and the build fails.</p>
<pre><code class="language-bash">#!/usr/bin/env bash
set -euo pipefail

echo "🚀 Running quality checks"

dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test --no-pub --coverage

if command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1; then
  dart_code_metrics analyze lib --reporter=console || true
fi

echo "✅ Quality checks passed"
</code></pre>
<p><strong>What each step does:</strong></p>
<p><code>dart format --output=none --set-exit-if-changed .</code>: checks that all Dart files are formatted correctly without modifying them. If any file doesn't match the formatter's output, the command exits with a non-zero code, failing the build. Formatting is non-negotiable here.</p>
<p><code>flutter analyze</code>: runs Dart's static analyser across the entire project. It catches null safety violations, unused imports, missing awaits, dead code, and a wide range of structural issues before they reach a reviewer's eyes.</p>
<p><code>flutter test --no-pub --coverage</code>: runs the full test suite and generates a coverage report at <code>coverage/lcov.info</code>. The <code>--no-pub</code> flag skips <code>pub get</code> since dependencies are already installed. The coverage file is used downstream to enforce a minimum threshold.</p>
<p>The <code>dart_code_metrics</code> block is deliberately optional and non-blocking (<code>|| true</code>). The tool may not be installed in every environment, and its findings are advisory rather than hard failures. You can remove the <code>|| true</code> later to make it mandatory once your team has adopted the tool.</p>
<p>The final <code>echo</code> line only executes if every step above it passed , because <code>set -e</code> would have exited the script on any earlier failure. If you see it in the logs, the branch is clean.</p>
<h3 id="heading-uploadsymbolssh">upload_symbols.sh</h3>
<p>When Flutter production builds are compiled with <code>--obfuscate</code>, stack traces in crash reports become unreadable. This script uploads the debug symbol files that Sentry needs to reverse that obfuscation and show readable crash reports.</p>
<pre><code class="language-bash">#!/usr/bin/env bash
set -euo pipefail

RELEASE=${1:-}

[ -z "$RELEASE" ] &amp;&amp; exit 2

if ! command -v sentry-cli &gt;/dev/null 2&gt;&amp;1; then
  exit 0
fi

sentry-cli releases new "$RELEASE" || true
sentry-cli upload-dif build/symbols || true
sentry-cli releases finalize "$RELEASE" || true

echo "✅ Symbols uploaded for release $RELEASE"
</code></pre>
<p><strong>How it works:</strong></p>
<p>The script takes a single argument: a release identifier. In practice, this is always the short Git commit SHA, passed from the workflow as <code>$(git rev-parse --short HEAD)</code>. This ties the uploaded symbols, the deployed build, and the crash reports in Sentry to the exact same commit , which is essential for production debugging.</p>
<p>If <code>sentry-cli</code> is not installed in the environment, the script exits with <code>0</code> rather than failing. This makes symbol uploads environment-aware: production machines install the CLI, development environments skip the step cleanly without breaking the build.</p>
<p>Each <code>sentry-cli</code> command uses <code>|| true</code> for resilience. Symbol uploads should never block a deployment , if the upload encounters a transient issue, the build should still succeed and the symbols can be re-uploaded manually from the stored artifacts.</p>
<p>The three commands do the following in sequence: <code>releases new</code> registers the release version in Sentry, <code>upload-dif</code> sends the debug information files from <code>build/symbols</code> (generated by <code>--split-debug-info</code>), and <code>releases finalize</code> marks the release as deployed and ready to aggregate crash reports.</p>
<h2 id="heading-the-codemagicyaml-structure">The codemagic.yaml Structure</h2>
<p>A <code>codemagic.yaml</code> file is organized around workflows. Each workflow is an independent pipeline definition with its own trigger rules, environment configuration, build scripts, and publishing targets. Multiple workflows live inside the same file under a top-level <code>workflows</code> key.</p>
<p>The skeleton looks like this:</p>
<pre><code class="language-yaml">workflows:
  pr-quality-gate:
    # triggers on pull requests
    # runs quality checks only

  android-pipeline:
    # triggers on push to develop, staging, production
    # handles Android builds and distribution

  ios-pipeline:
    # triggers on push to develop, staging, production
    # handles iOS builds and distribution
</code></pre>
<p>Each workflow can define its own machine type, environment variables, triggering conditions, and step scripts. This is what makes a single <code>codemagic.yaml</code> powerful: you're not managing three separate files, but you still get complete isolation between pipeline stages.</p>
<h2 id="heading-pr-quality-gate">PR Quality Gate</h2>
<p>Every PR raised against <code>develop</code> must pass a quality gate before any merge is allowed. This workflow runs on Codemagic's Linux machines since it doesn't need to produce a signed artifact for any platform – it only needs to verify the code.</p>
<pre><code class="language-yaml">workflows:
  pr-quality-gate:
    name: PR Quality Gate
    max_build_duration: 30
    instance_type: linux_x2

    triggering:
      events:
        - pull_request
      branch_patterns:
        - pattern: develop
          include: true
          source: true

    environment:
      flutter: stable

    scripts:
      - name: Install dependencies
        script: flutter pub get

      - name: Run quality checks
        script: ./scripts/quality_checks.sh

      - name: Enforce coverage threshold
        script: |
          COVERAGE=\((lcov --summary coverage/lcov.info | grep lines | awk '{print \)2}' | sed 's/%//')
          if [ \((echo "\)COVERAGE &lt; 70" | bc) -eq 1 ]; then
            echo "Test coverage is at ${COVERAGE}% — minimum required is 70%"
            exit 1
          fi
          echo "Coverage at ${COVERAGE}% — threshold met"

    publishing:
      email:
        recipients:
          - your-team@example.com
        notify:
          success: true
          failure: true
</code></pre>
<p>Let's walk through what each section is doing.</p>
<p><code>instance_type: linux_x2</code></p>
<p>Codemagic offers different machine types for different workloads. For a quality gate that only needs to run Dart tooling, a Linux machine is perfectly sufficient and significantly cheaper than a macOS instance. You reserve the macOS machines for builds that actually need Xcode.</p>
<p><code>triggering</code></p>
<p>This is how Codemagic decides when to run a workflow. The <code>pull_request</code> event fires whenever a PR is opened or updated. The <code>branch_patterns</code> block tells Codemagic to watch for PRs targeting <code>develop</code> specifically. The <code>source: true</code> flag means this pattern applies to the target branch of the PR, not the source branch – so any branch raising a PR into <code>develop</code> will trigger this workflow.</p>
<p><code>environment</code></p>
<p>Codemagic's Flutter-aware machines come with multiple Flutter versions available. Setting <code>flutter: stable</code> pins the workflow to the current stable channel without requiring any manual SDK installation step. This is one of the areas where Codemagic saves setup time compared to a general-purpose runner.</p>
<p><strong>Quality checks script</strong></p>
<p>The workflow delegates to <code>quality_checks.sh</code> rather than inlining commands. This keeps the YAML readable and ensures the exact same logic runs when a developer calls the script locally. The script handles formatting, analysis, and test execution internally.</p>
<p><strong>Coverage enforcement</strong></p>
<p>After the tests run, <code>lcov</code> parses the coverage report generated by <code>flutter test --coverage</code> and extracts the line coverage percentage. If it falls below 70%, the build fails with a clear message. This threshold is something your team should agree on , 70% is a reasonable starting point for most projects.</p>
<p><code>publishing</code></p>
<p>Codemagic has native email notification support built in. Rather than scripting <code>echo</code> statements into CI logs, you declare recipients directly in the workflow and Codemagic handles delivery. Both success and failure states are covered.</p>
<h2 id="heading-android-pipeline">Android Pipeline</h2>
<p>The Android workflow handles all three environments in a single workflow definition, using Codemagic's environment variable groups and conditional scripting to behave differently depending on which branch triggered the build.</p>
<pre><code class="language-yaml">  android-pipeline:
    name: Android Build &amp; Release
    max_build_duration: 60
    instance_type: linux_x2

    triggering:
      events:
        - push
      branch_patterns:
        - pattern: develop
          include: true
        - pattern: staging
          include: true
        - pattern: production
          include: true

    environment:
      flutter: stable
      android_signing:
        - android_keystore
      groups:
        - staging_secrets
        - production_secrets
        - firebase_credentials
        - sentry_credentials

    scripts:
      - name: Install dependencies
        script: flutter pub get

      - name: Detect environment
        script: |
          BRANCH=$(git rev-parse --abbrev-ref HEAD)
          if [ "$BRANCH" = "develop" ]; then
            echo "ENV=dev" &gt;&gt; $CM_ENV
          elif [ "$BRANCH" = "staging" ]; then
            echo "ENV=staging" &gt;&gt; $CM_ENV
          else
            echo "ENV=production" &gt;&gt; $CM_ENV
          fi

      - name: Generate environment config
        script: |
          if [ "$ENV" = "dev" ]; then
            ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"
          elif [ "$ENV" = "staging" ]; then
            ./scripts/generate_config.sh staging "\(STAGING_BASE_URL" "\)STAGING_API_KEY"
          else
            ./scripts/generate_config.sh production "\(PROD_BASE_URL" "\)PROD_API_KEY"
          fi

      - name: Build Android artifact
        script: |
          if [ "$ENV" = "production" ]; then
            flutter build appbundle --release \
              --obfuscate \
              --split-debug-info=build/symbols
          else
            flutter build appbundle --release
          fi

      - name: Distribute to Firebase App Distribution
        script: |
          if [ "\(ENV" = "dev" ] || [ "\)ENV" = "staging" ]; then
            firebase appdistribution:distribute \
              build/app/outputs/bundle/release/app-release.aab \
              --app "$FIREBASE_ANDROID_APP_ID" \
              --groups "$FIREBASE_GROUPS" \
              --token "$FIREBASE_TOKEN"
          fi

      - name: Submit to Play Store
        script: |
          if [ "$ENV" = "production" ]; then
            echo "$GOOGLE_PLAY_SERVICE_ACCOUNT_JSON" &gt; /tmp/service_account.json
            flutter pub global activate fastlane 2&gt;/dev/null || true
            fastlane supply \
              --aab build/app/outputs/bundle/release/app-release.aab \
              --json_key /tmp/service_account.json \
              --package_name com.your.package \
              --track production
          fi

      - name: Upload Sentry symbols
        script: |
          if [ "$ENV" = "production" ]; then
            ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
          fi

    artifacts:
      - build/app/outputs/bundle/release/app-release.aab
      - build/symbols/**

    publishing:
      email:
        recipients:
          - your-team@example.com
        notify:
          success: true
          failure: true
</code></pre>
<p>Here is what each section is doing and why it's designed this way.</p>
<p><code>android_signing</code></p>
<p>This is one of Codemagic's most valuable features. Instead of manually decoding a Base64 keystore and writing it to disk inside a script, you upload your keystore file directly to Codemagic's encrypted key storage under Teams → Code signing identities → Android keystores. You give it a reference name – <code>android_keystore</code> in this case – and Codemagic handles decoding, placement, and <code>key.properties</code> generation automatically before your build scripts run.</p>
<p>This eliminates an entire category of signing-related build failures.</p>
<p><code>groups</code></p>
<p>Codemagic lets you organize secrets into named groups in the environment variables section of your team settings. Rather than declaring individual secrets inline, you reference groups. The groups used here are:</p>
<ul>
<li><p><code>staging_secrets</code>: contains <code>STAGING_BASE_URL</code> and <code>STAGING_API_KEY</code></p>
</li>
<li><p><code>production_secrets</code>: contains <code>PROD_BASE_URL</code> and <code>PROD_API_KEY</code></p>
</li>
<li><p><code>firebase_credentials</code>: contains <code>FIREBASE_TOKEN</code>, <code>FIREBASE_ANDROID_APP_ID</code>, <code>FIREBASE_GROUPS</code></p>
</li>
<li><p><code>sentry_credentials</code>: contains <code>SENTRY_AUTH_TOKEN</code>, <code>SENTRY_ORG</code>, <code>SENTRY_PROJECT</code></p>
</li>
</ul>
<p><strong>Environment detection with</strong> <code>$CM_ENV</code></p>
<p>Codemagic exposes a special file path via the <code>$CM_ENV</code> variable. Writing <code>KEY=VALUE</code> to this file makes that variable available to every subsequent script step in the same build. This is how the branch name gets translated into an environment label that the rest of the pipeline reads.</p>
<p><strong>Build differentiation</strong></p>
<p>Production builds use <code>--obfuscate</code> and <code>--split-debug-info=build/symbols</code>. Dev and staging builds skip both flags for faster compilation and readable local stack traces.</p>
<p><strong>Firebase distribution</strong></p>
<p>The Firebase CLI distributes dev and staging builds to testers. Because Codemagic's Linux machines come with Node.js available, you can install the Firebase CLI with <code>npm install -g firebase-tools</code> as a setup step if it is not already present, or invoke it via <code>npx</code>.</p>
<p><strong>Play Store submission</strong></p>
<p>Production app bundles go to the Play Store using Fastlane's <code>supply</code> command. The service account JSON is written to a temporary file from the environment variable and passed to Fastlane directly. Replace <code>com.your.package</code> with your actual application ID.</p>
<p><code>artifacts</code></p>
<p>The artifacts section tells Codemagic which files to preserve after the build completes. These files become downloadable from the Codemagic build dashboard. The debug symbols are captured here as well, which is useful for manual Sentry uploads if the automated step ever needs to be re-run.</p>
<h2 id="heading-ios-pipeline">iOS Pipeline</h2>
<p>iOS on Codemagic is where the platform's advantage becomes most visible. Apple code signing on a general-purpose runner requires a multi-step keychain dance involving <code>security</code> commands, certificate imports, and provisioning profile placement. Codemagic handles all of that automatically through its native signing integration.</p>
<pre><code class="language-yaml">  ios-pipeline:
    name: iOS Build &amp; Release
    max_build_duration: 90
    instance_type: mac_mini_m2

    triggering:
      events:
        - push
      branch_patterns:
        - pattern: develop
          include: true
        - pattern: staging
          include: true
        - pattern: production
          include: true

    environment:
      flutter: stable
      ios_signing:
        distribution_type: app_store
        bundle_identifier: com.your.bundle.id
      groups:
        - staging_secrets
        - production_secrets
        - app_store_credentials
        - sentry_credentials

    scripts:
      - name: Install dependencies
        script: flutter pub get

      - name: Install Fastlane dependencies
        script: |
          cd ios
          gem install bundler --user-install
          bundle install

      - name: Detect environment
        script: |
          BRANCH=$(git rev-parse --abbrev-ref HEAD)
          if [ "$BRANCH" = "develop" ]; then
            echo "ENV=dev" &gt;&gt; $CM_ENV
          elif [ "$BRANCH" = "staging" ]; then
            echo "ENV=staging" &gt;&gt; $CM_ENV
          else
            echo "ENV=production" &gt;&gt; $CM_ENV
          fi

      - name: Generate environment config
        script: |
          if [ "$ENV" = "dev" ]; then
            ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"
          elif [ "$ENV" = "staging" ]; then
            ./scripts/generate_config.sh staging "\(STAGING_BASE_URL" "\)STAGING_API_KEY"
          else
            ./scripts/generate_config.sh production "\(PROD_BASE_URL" "\)PROD_API_KEY"
          fi

      - name: Build iOS (dev — no signing)
        script: |
          if [ "$ENV" = "dev" ]; then
            flutter build ios --release --no-codesign
          fi

      - name: Build and ship to TestFlight (staging)
        script: |
          if [ "$ENV" = "staging" ]; then
            flutter build ipa --release \
              --export-options-plist=/Users/builder/export_options.plist
            cd ios &amp;&amp; bundle exec fastlane beta
          fi

      - name: Build and release to App Store (production)
        script: |
          if [ "$ENV" = "production" ]; then
            flutter build ipa --release \
              --obfuscate \
              --split-debug-info=build/symbols \
              --export-options-plist=/Users/builder/export_options.plist
            cd ios &amp;&amp; bundle exec fastlane release
          fi

      - name: Upload Sentry symbols
        script: |
          if [ "$ENV" = "production" ]; then
            ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
          fi

    artifacts:
      - build/ios/ipa/*.ipa
      - build/symbols/**
      - /tmp/xcodebuild_logs/*.log

    publishing:
      app_store_connect:
        api_key: $APP_STORE_CONNECT_PRIVATE_KEY
        key_id: $APP_STORE_CONNECT_KEY_IDENTIFIER
        issuer_id: $APP_STORE_CONNECT_ISSUER_ID
        submit_to_testflight: true
        submit_to_app_store: false
      email:
        recipients:
          - your-team@example.com
        notify:
          success: true
          failure: true
</code></pre>
<p>Here's what is different from the Android workflow and why.</p>
<p><code>mac_mini_m2</code></p>
<p>iOS builds require Xcode, which means they need macOS. Codemagic provides Apple Silicon Mac Mini instances. These are meaningfully faster than Intel-based runners for Flutter and Xcode workloads, and Codemagic provisions them on demand without any infrastructure management on your side.</p>
<p><code>ios_signing</code></p>
<p>This is the section that replaces the entire keychain setup sequence. You upload your distribution certificate and provisioning profile once to Codemagic's code signing identities under your team settings. The <code>distribution_type: app_store</code> tells Codemagic to use App Store distribution signing, and <code>bundle_identifier</code> ties it to your specific app. Before your scripts run, Codemagic installs the certificate and profile automatically on the build machine.</p>
<p>No <code>security</code> commands, no keychain creation, no Base64 decoding. It's handled internally.</p>
<p><code>flutter build ipa</code></p>
<p>On iOS, the build output is an <code>.ipa</code> file rather than an <code>.aab</code>. Flutter's <code>flutter build ipa</code> command produces this directly when provided with an export options plist. The plist tells Xcode how to sign and package the output. Codemagic generates this file automatically based on your <code>ios_signing</code> configuration and places it at <code>/Users/builder/export_options.plist</code>.</p>
<p><strong>Fastlane lanes</strong></p>
<p>Codemagic installs Fastlane via Bundler in the <code>ios/</code> directory, then calls the appropriate lane based on the detected environment. The <code>beta</code> lane uploads to TestFlight, and the <code>release</code> lane submits to the App Store.</p>
<p><code>publishing.app_store_connect</code></p>
<p>Codemagic has a native App Store Connect publisher. Rather than scripting the upload manually, you declare your API credentials in the publishing block and Codemagic handles the submission. The <code>submit_to_testflight: true</code> flag means staging builds are automatically available to TestFlight testers after the build completes. For production, you would flip <code>submit_to_app_store</code> to <code>true</code> instead.</p>
<p><strong>Xcode logs as artifacts</strong></p>
<p>The line <code>/tmp/xcodebuild_logs/*.log</code> captures raw Xcode build logs as downloadable artifacts. When an iOS build fails and the error message in the Codemagic dashboard is not specific enough, these logs are where you find the real cause.</p>
<h2 id="heading-environment-variables-and-secrets-reference">Environment Variables and Secrets Reference</h2>
<p>All secrets are configured in Codemagic under Teams → Environment variables. Group them logically so they can be referenced cleanly in the YAML.</p>
<p><strong>staging_secrets group</strong></p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>STAGING_BASE_URL</code></td>
<td>Staging API base URL</td>
</tr>
<tr>
<td><code>STAGING_API_KEY</code></td>
<td>Staging API or encryption key</td>
</tr>
</tbody></table>
<p><strong>production_secrets group</strong></p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>PROD_BASE_URL</code></td>
<td>Production API base URL</td>
</tr>
<tr>
<td><code>PROD_API_KEY</code></td>
<td>Production API or encryption key</td>
</tr>
</tbody></table>
<p><strong>firebase_credentials group</strong></p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>FIREBASE_TOKEN</code></td>
<td>Generated via <code>firebase login:ci</code></td>
</tr>
<tr>
<td><code>FIREBASE_ANDROID_APP_ID</code></td>
<td>Android app ID from Firebase console</td>
</tr>
<tr>
<td><code>FIREBASE_GROUPS</code></td>
<td>Comma-separated tester group names</td>
</tr>
</tbody></table>
<p><strong>app_store_credentials group</strong></p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>APP_STORE_CONNECT_PRIVATE_KEY</code></td>
<td>Contents of the <code>.p8</code> key file from App Store Connect</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_KEY_IDENTIFIER</code></td>
<td>Key ID from App Store Connect</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_ISSUER_ID</code></td>
<td>Issuer ID from App Store Connect</td>
</tr>
<tr>
<td><code>GOOGLE_PLAY_SERVICE_ACCOUNT_JSON</code></td>
<td>Full JSON of your Play Console service account</td>
</tr>
</tbody></table>
<p><strong>sentry_credentials group</strong></p>
<table>
<thead>
<tr>
<th>Variable</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>SENTRY_AUTH_TOKEN</code></td>
<td>Auth token from Sentry account settings</td>
</tr>
<tr>
<td><code>SENTRY_ORG</code></td>
<td>Your Sentry organization slug</td>
</tr>
<tr>
<td><code>SENTRY_PROJECT</code></td>
<td>Your Sentry project slug</td>
</tr>
</tbody></table>
<p>For Android code signing, upload your keystore directly under Teams → Code signing identities → Android keystores rather than storing it as an environment variable.</p>
<p>For iOS, upload your distribution certificate and provisioning profile under Teams → Code signing identities → iOS certificates.</p>
<h2 id="heading-end-to-end-flow">End-to-End Flow</h2>
<p>With the full <code>codemagic.yaml</code> in place, here is the complete picture of what happens across a typical release cycle.</p>
<p>A developer finishes a feature and raises a PR into <code>develop</code>. Codemagic detects the pull request event and triggers the <code>pr-quality-gate</code> workflow on a Linux machine. The quality checks script runs formatting, analysis, tests, and coverage threshold check. If anything fails, Codemagic marks the build as failed, sends the team an email, and the PR cannot be considered ready. The developer pushes a fix, Codemagic runs again, and only when everything passes does the PR move forward.</p>
<p>Once the PR merges into <code>develop</code>, both the <code>android-pipeline</code> and <code>ios-pipeline</code> trigger simultaneously. Each detects <code>develop</code> as the source branch, maps it to the dev environment, injects placeholder config, builds an unsigned release artifact, and ships it to Firebase App Distribution. Testers have an installable build within minutes of the merge completing.</p>
<p>When <code>develop</code> is merged into <code>staging</code>, the same two platform pipelines fire again. This time real secrets are injected , the staging API URL, the staging encryption key. Android builds are signed with the keystore Codemagic manages automatically. iOS builds go through Fastlane's <code>beta</code> lane to TestFlight. The Codemagic App Store Connect publisher handles the TestFlight upload natively. QA now has a properly signed, properly configured staging build to test against.</p>
<p>When <code>staging</code> is promoted to <code>production</code>, the pipelines enter release mode. Production secrets are injected. Android builds are obfuscated with debug symbols split into <code>build/symbols</code>. iOS builds go through <code>flutter build ipa</code> with obfuscation enabled. Both platform pipelines call <code>upload_symbols.sh</code> with the current commit SHA, linking the Sentry release to the exact code that shipped. The Android bundle goes to the Play Store via Fastlane. The iOS IPA is submitted to App Store Connect via Codemagic's native publisher. The team receives a success notification.</p>
<p>That's the full cycle. No terminal, no manual step, no shared Slack message saying "I think I deployed staging."</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The pipeline we just built covers the full release lifecycle: automated quality enforcement, environment-aware config injection, platform-specific signed builds, tester distribution, crash observability, and store submission , all from a single <code>codemagic.yaml</code> file.</p>
<p>What Codemagic brings to this setup is a tighter integration with the mobile ecosystem specifically. The keystore management, native App Store Connect publisher, pre-installed Flutter toolchain, and Apple Silicon Mac instances aren't add-ons you configure , they're part of the platform's core. This translates into fewer steps to maintain, fewer failure surfaces, and a pipeline that's easier to reason about when something does go wrong.</p>
<p>The scripts in your <code>scripts/</code> folder remain completely platform-agnostic. If your team ever needs to move pipelines, those scripts move with you unchanged. The YAML changes, but the logic doesn't.</p>
<p>What you have at the end of this setup is a release process your team can trust: one where "did it deploy?" is answered by a notification, not a question in Slack.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Build a Production-Ready Flutter CI/CD Pipeline with GitHub Actions: Quality Gates, Environments, and Store Deployment ]]>
                </title>
                <description>
                    <![CDATA[ Mobile application development has evolved over the years. The processes, structure, and syntax we use has changed, as well as the quality and flexibility of the apps we build. One of the major improv ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-build-a-production-ready-flutter-ci-cd-pipeline-with-github-actions-quality-gates-environments-and-store-deployment/</link>
                <guid isPermaLink="false">69bb2e078c55d6eefb6c2e8d</guid>
                
                    <category>
                        <![CDATA[ ci-cd ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ github-actions ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Devops ]]>
                    </category>
                
                    <category>
                        <![CDATA[ github copilot ]]>
                    </category>
                
                    <category>
                        <![CDATA[ CI/CD pipelines ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Productivity ]]>
                    </category>
                
                    <category>
                        <![CDATA[ automation ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Wed, 18 Mar 2026 22:58:15 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/uploads/covers/5e1e335a7a1d3fcc59028c64/8c9d9384-ff02-47d7-aa69-42db2ebae247.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>Mobile application development has evolved over the years. The processes, structure, and syntax we use has changed, as well as the quality and flexibility of the apps we build.</p>
<p>One of the major improvements has been a properly automated CI/CD pipeline flow that gives us seamless automation, continuous integration, and continuous deployment.</p>
<p>In this article, I'll break down how you can automate and build a production ready CI/CD pipeline for your Flutter application using GitHub Actions.</p>
<p>Note that there are other ways to do this, like with Codemagic (built specifically for Flutter apps – which I'll cover in a subsequent tutorial), but in this article we'll focus on GitHub Actions instead.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a href="#heading-the-typical-workflow">The Typical Workflow</a></p>
</li>
<li><p><a href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a href="#heading-pipeline-architecture">Pipeline Architecture</a></p>
</li>
<li><p><a href="#heading-writing-the-workflows">Writing the Workflows</a></p>
<ul>
<li><p><a href="#heading-the-helper-scripts">The Helper Scripts</a></p>
<ul>
<li><p><a href="#heading-script-1-generateconfigsh">generate_config.sh</a></p>
</li>
<li><p><a href="#heading-script-2-qualitygatesh">quality_gate.sh</a></p>
</li>
<li><p><a href="#heading-script-3-uploadsymbolssh-sentry">upload_symbols.sh</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-workflow-1-prchecksyml">PR Quality Gate (pr_checks.yml)</a></p>
</li>
<li><p><a href="#heading-workflow-2-androidyml">Android CI/CD Pipeline (android.yml)</a></p>
</li>
<li><p><a href="#heading-workflow-3-iosyml">iOS CI/CD Pipeline (ios.yml)</a></p>
</li>
</ul>
</li>
<li><p><a href="#heading-secrets-and-configuration-reference">Secrets and Configuration Reference</a></p>
</li>
<li><p><a href="#heading-end-to-end-flow">End-to-End Flow</a></p>
</li>
<li><p><a href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-the-typical-workflow">The Typical Workflow</h2>
<p>First, let's define the common approach to deploying production-ready Flutter apps.</p>
<p>The development team does their work on local, pushes to the repository for merge or review, and eventually runs <code>flutter build apk</code> or <code>flutter build appbundle</code> to generate the apk file. This then gets shared with the QA team manually, or deployed to Firebase app distribution for testing. If it's a production move, the app bundle is submitted to the Google Play store for review and then deployed.</p>
<p>This process is often fully manual with no automated checks, validation, or control over quality, speed, and seamlessness. Manually shipping a Flutter app starts out relatively simply, but can quickly and quietly turn into a liability. You run <code>flutter build</code>, switch configs, sign the build, upload it somewhere, and hope you didn’t mix up staging keys with production ones.</p>
<p>As teams grow and release updates more and more quickly, these manual steps become real risks. A skipped quality check, a missing keystore, or an incorrect base URL deployed to production can cost hours of debugging or worse – it can affect your users.</p>
<p>Automating this process fully involves some high level configuration and predefined scripting. It completely takes control of the deployment process from the moment the developer raised a PR into the common or base branch (for example, the <code>develop</code> branch).</p>
<p>This automated process takes care of everything that needs to be done – provided it has been predefined, properly scripted, and aligns with the use case of the team.</p>
<h3 id="heading-what-well-do-here">What we'll do here:</h3>
<p>In this tutorial, we'll build a production-grade CI/CD pipeline for a Flutter app using GitHub Actions. The pipeline automates the entire lifecycle: pull-request quality checks, environment-specific configuration injection, Android and iOS builds, Firebase App Distribution for testers, Sentry symbol uploads, and final deployment to the Play Store and App Store.</p>
<p>By the end, every release – from a developer opening a PR to the final build landing in users' hands – will be fully automated, with no one touching a terminal.</p>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before starting, you should have:</p>
<ol>
<li><p>A Flutter app with working Android and iOS builds</p>
</li>
<li><p>Basic familiarity with <a href="https://www.freecodecamp.org/news/automate-cicd-with-github-actions-streamline-workflow/">GitHub Actions</a> (workflows and jobs)</p>
</li>
<li><p>A Firebase project with App Distribution enabled</p>
</li>
<li><p>A Sentry project for error tracking</p>
</li>
<li><p>A Google Play Console app already created</p>
</li>
<li><p>An Apple Developer account with App Store Connect access</p>
</li>
<li><p>Fastlane configured for your iOS project</p>
</li>
<li><p>Basic Bash knowledge (I’ll explain the important parts)</p>
</li>
</ol>
<h2 id="heading-pipeline-architecture">Pipeline Architecture</h2>
<p>In this guide, we'll be building a CI/CD pipeline with very precise instructions and use cases. These use cases determine the way your pipeline is built.</p>
<p>For this tutorial, we'll use this use case:</p>
<p>I want to automate the workflow on my development team based on the following criteria:</p>
<ol>
<li><p>When a developer on the team raises a PR into the common working branch <code>develop</code> in most cases), a workflow is triggered to run quality checks on the code. It only allows the merge to happen if all checks (like tests coverage, quality checks, and static analysis) pass.</p>
</li>
<li><p>Code that's moving from the develop branch to the staging branch goes through another workflow that injects staging configurations/secret keys, does all the necessary checks, and distributes the application for testing on Firebase App Distribution for android as well as Testflight for iOS.</p>
</li>
<li><p>Code that's moving from the staging to the production branch goes through the production level workflow which involves apk secured signing, production configuration injection, running tests to ensure nothing breaks, Sentry analysis for monitoring, and submission to App Store Connect as well as Google Play Console.</p>
</li>
</ol>
<p>These are our predefined conditions which help with the construction of our workflows.</p>
<h2 id="heading-writing-the-workflows">Writing the Workflows</h2>
<p>We'll split this pipeline into three GitHub Actions workflows.</p>
<p>We'll also be taking it a notch higher by creating three helper .sh scripts for a cleaner and more maintainable workflow.</p>
<p>In your project root, create two folders:</p>
<ol>
<li><p>.github/</p>
</li>
<li><p>scripts.</p>
</li>
</ol>
<p>The <strong>.github/</strong> folder will hold the workflows we'll be creating for each use case, while the <strong>scripts/</strong> folder will hold the helper scripts that we can easily call in our CLI or in the workflows directly.</p>
<p>After this, we'll create three workflow .yaml files:</p>
<ol>
<li><p>pr_checks.yaml</p>
</li>
<li><p>android.yaml</p>
</li>
<li><p>ios.yaml</p>
</li>
</ol>
<p>Also in the scripts folder, let's create three .sh files:</p>
<ol>
<li><p>generate_config.sh</p>
</li>
<li><p>quality_checks.sh</p>
</li>
<li><p>upload_symbols.sh</p>
</li>
</ol>
<pre><code class="language-yaml">.github/
  workflows/
    pr_checks.yml
    android.yml
    ios.yml

scripts/
  generate_config.sh
  quality_checks.sh
  upload_symbols.sh
</code></pre>
<p>This workflow architecture ensures that a push to <code>develop</code> automatically produces a tester build. Also, merging to <code>production</code> ships directly to the stores without manual commands or config changes.</p>
<p>The scripts live outside the YAML on purpose. This lets you run the same logic locally.</p>
<h3 id="heading-the-helper-scripts">The Helper Scripts</h3>
<p>The scripts form the backbone of the pipeline. Each one has a single responsibility and is reused across workflows.</p>
<p>Instead of cramming logic into YAML, we'll move it into <strong>reusable scripts</strong>. This keeps workflows clean and lets you run the same logic locally. Let's go through each one now.</p>
<h3 id="heading-script-1-generateconfigsh">Script #1: <code>generate_config.sh</code></h3>
<p>Injecting secrets safely is one of the hardest CI/CD problems in mobile apps.</p>
<p>The strategy:</p>
<ul>
<li><p>Commit a Dart template file with placeholders</p>
</li>
<li><p>Replace placeholders at build time using secrets from GitHub Actions</p>
</li>
<li><p>Never commit real credentials</p>
</li>
</ul>
<pre><code class="language-yaml">#!/usr/bin/env bash
set -euo pipefail


ENV_NAME=${1:-}
BASE_URL=${2:-}
ENCRYPTION_KEY=${3:-}

TEMPLATE="lib/core/env/env_ci.dart"
OUT="lib/core/env/env_ci.g.dart"

if [ -z "\(ENV_NAME" ] || [ -z "\)BASE_URL" ] || [ -z "$ENCRYPTION_KEY" ]; then
  echo "Usage: $0 &lt;env-name&gt; &lt;base-url&gt; &lt;encryption-key&gt;"
  exit 2
fi

sed -e "s|&lt;&lt;BASE_URL&gt;&gt;|$BASE_URL|g" \
    -e "s|&lt;&lt;ENCRYPTION_KEY&gt;&gt;|$ENCRYPTION_KEY|g" \
    -e "s|&lt;&lt;ENV_NAME&gt;&gt;|$ENV_NAME|g" \
    "\(TEMPLATE" &gt; "\)OUT"

echo "Generated config for $ENV_NAME"
</code></pre>
<p>This script is responsible for injecting environment-specific configuration into the Flutter app at build time, without ever committing secrets to source control.</p>
<p>Let’s walk through it carefully.</p>
<h4 id="heading-1-shebang-choosing-the-shell">1. Shebang: Choosing the Shell</h4>
<pre><code class="language-yaml">#!/usr/bin/env bash
</code></pre>
<p>This line tells the system to execute the script using <strong>Bash</strong>, regardless of where Bash is installed on the machine.</p>
<p>Using <code>/usr/bin/env bash</code> instead of <code>/bin/bash</code> makes the script more portable across local machines, GitHub Actions runners, and Docker containers.</p>
<h4 id="heading-2-fail-fast-fail-loud">2. Fail Fast, Fail Loud</h4>
<pre><code class="language-yaml">set -euo pipefail
</code></pre>
<p>This is one of the most important lines in the script.</p>
<p>It enables three strict Bash modes:</p>
<ul>
<li><p><code>-e</code>: Exit immediately if any command fails</p>
</li>
<li><p><code>-u</code>: Exit if an undefined variable is used</p>
</li>
<li><p><code>-o pipefail</code>: Fail if any command in a pipeline fails, not just the last one</p>
</li>
</ul>
<p>This matters in CI because silent failures are dangerous, partial config generation can break production builds, and CI should stop immediately when something is wrong.</p>
<p>This line ensures that no broken config ever makes it into a build.</p>
<h4 id="heading-3-reading-input-arguments">3. Reading Input Arguments</h4>
<pre><code class="language-yaml">
ENV_NAME=${1:-}
BASE_URL=${2:-}
ENCRYPTION_KEY=${3:-}
</code></pre>
<p>These lines read <strong>positional arguments</strong> passed to the script:</p>
<ul>
<li><p><code>$1</code>: Environment name (<code>dev</code>, <code>staging</code>, <code>production</code>)</p>
</li>
<li><p><code>$2</code>: API base URL</p>
</li>
<li><p><code>$3</code>: Encryption or API key</p>
</li>
</ul>
<p>The <code>${1:-}</code> syntax means:</p>
<p><em>“If the argument is missing, default to an empty string instead of crashing.”</em></p>
<p>This works hand-in-hand with <code>set -u</code> , we control the failure explicitly instead of letting Bash explode unexpectedly.</p>
<h4 id="heading-4-defining-input-and-output-files">4. Defining Input and Output Files</h4>
<pre><code class="language-yaml">TEMPLATE="lib/core/env/env_ci.dart"
OUT="lib/core/env/env_ci.g.dart"
</code></pre>
<p>Here we define two files:</p>
<ul>
<li><p><strong>Template file (</strong><code>env_ci.dart</code><strong>)</strong></p>
<ul>
<li><p>Contains placeholder values like <code>&lt;&lt;BASE_URL&gt;&gt;</code></p>
</li>
<li><p>Safe to commit to Git</p>
</li>
</ul>
</li>
<li><p><strong>Generated file (</strong><code>env_ci.g.dart</code><strong>)</strong></p>
<ul>
<li><p>Contains real environment values</p>
</li>
<li><p>Must be ignored by Git (<code>.gitignore</code>)</p>
</li>
</ul>
</li>
</ul>
<p>At the heart of this approach are two Dart files with very different responsibilities. They may look similar, but they play completely different roles in the system.</p>
<h4 id="heading-envcidart"><code>env.ci.dart</code>:</h4>
<pre><code class="language-java">// lib/core/env/env_ci.dart

class EnvConfig {
  static const String baseUrl = '&lt;&lt;BASE_URL&gt;&gt;';
  static const String encryptionKey = '&lt;&lt;ENCRYPTION_KEY&gt;&gt;';
  static const String environment = '&lt;&lt;ENV_NAME&gt;&gt;';
}
</code></pre>
<p>This file is <strong>safe</strong>, <strong>static</strong>, and <strong>version-controlled</strong>. It contains placeholders, not real values.</p>
<p>Some of its key characteristics are:</p>
<ul>
<li><p>Contains no real secrets</p>
</li>
<li><p>Uses obvious placeholders (<code>&lt;&lt;BASE_URL&gt;&gt;</code>, etc.)</p>
</li>
<li><p>Safe to commit to Git</p>
</li>
<li><p>Reviewed like normal source code</p>
</li>
<li><p>Serves as the single source of truth for required config fields</p>
</li>
</ul>
<p>Think of this file as a contract:</p>
<p><em>“These are the configuration values the app expects at runtime.”</em></p>
<h4 id="heading-envcigdart"><code>env.ci.g.dart</code>:</h4>
<p>This file is created at <strong>build time</strong> by <code>generate_config.sh</code>. After substitution, it looks like this:</p>
<pre><code class="language-java">// lib/core/env/env_ci.g.dart
// GENERATED FILE — DO NOT COMMIT

class EnvConfig {
  static const String baseUrl = 'https://staging.api.example.com';
  static const String encryptionKey = 'sk_live_xxxxx';
  static const String environment = 'staging';
}
</code></pre>
<p>Key characteristics:</p>
<ul>
<li><p>Contains real environment values</p>
</li>
<li><p>Generated dynamically in CI</p>
</li>
<li><p>Differs per environment (dev / staging / production)</p>
</li>
<li><p>Must <strong>never</strong> be committed to source control</p>
</li>
</ul>
<p>This file exists only on a developer’s machine (if generated locally), inside the CI runner during a build. Once the job finishes, it disappears.</p>
<h4 id="heading-gitignore"><code>.gitignore</code>:</h4>
<p>To guarantee the generated file never leaks, it must be ignored:</p>
<h4 id="heading-why-this-separation-is-critical">Why This Separation Is Critical</h4>
<p>This design solves several hard problems at once.</p>
<p><strong>Security:</strong></p>
<ul>
<li><p>Secrets live <strong>only</strong> in GitHub Actions secrets</p>
</li>
<li><p>They never appear in the repository</p>
</li>
<li><p>They never appear in PRs</p>
</li>
<li><p>They never appear in Git history</p>
</li>
</ul>
<p><strong>Environment Isolation:</strong></p>
<p>Each environment gets its own generated config:</p>
<ul>
<li><p><code>develop</code>: dev API</p>
</li>
<li><p><code>staging</code>: staging API</p>
</li>
<li><p><code>production</code>: production API</p>
</li>
</ul>
<p>The same codebase behaves differently <strong>without branching logic in Dart</strong>.</p>
<p><strong>Deterministic Builds:</strong></p>
<p>Every build is fully reproducible, fully automated, and explicit about which environment it targets.</p>
<p>There are no “it worked locally” scenarios.</p>
<h4 id="heading-5-validating-required-arguments">5. Validating Required Arguments</h4>
<pre><code class="language-java">if [ -z "\(ENV_NAME" ] || [ -z "\)BASE_URL" ] || [ -z "$ENCRYPTION_KEY" ]; then
  echo "Usage: $0 &lt;env-name&gt; &lt;base-url&gt; &lt;encryption-key&gt;"
  exit 2
fi
</code></pre>
<p>This block enforces correct usage.</p>
<ul>
<li><p><code>-z</code> checks whether a variable is empty</p>
</li>
<li><p>If any required argument is missing:</p>
<ul>
<li><p>A helpful usage message is printed</p>
</li>
<li><p>The script exits with a non-zero status code</p>
</li>
</ul>
</li>
<li><p><code>0</code>: success</p>
</li>
<li><p><code>1+</code>: failure</p>
</li>
<li><p><code>2</code> conventionally means incorrect usage</p>
</li>
</ul>
<p>In CI, this immediately fails the job and prevents an invalid build.</p>
<h4 id="heading-6-injecting-environment-values">6. Injecting Environment Values</h4>
<pre><code class="language-java">sed -e "s|&lt;&lt;BASE_URL&gt;&gt;|$BASE_URL|g" \
    -e "s|&lt;&lt;ENCRYPTION_KEY&gt;&gt;|$ENCRYPTION_KEY|g" \
    -e "s|&lt;&lt;ENV_NAME&gt;&gt;|$ENV_NAME|g" \
    "\(TEMPLATE" &gt; "\)OUT"
</code></pre>
<p>This is the heart of the script.</p>
<p>What’s happening here:</p>
<ol>
<li><p><code>sed</code> performs <strong>stream editing</strong>: it reads text, transforms it, and outputs the result</p>
</li>
<li><p>Each <code>-e</code> flag defines a replacement rule:</p>
<ul>
<li><p>Replace <code>&lt;&lt;BASE_URL&gt;&gt;</code> with the actual API URL</p>
</li>
<li><p>Replace <code>&lt;&lt;ENCRYPTION_KEY&gt;&gt;</code> with the real key</p>
</li>
<li><p>Replace <code>&lt;&lt;ENV_NAME&gt;&gt;</code> with the environment label</p>
</li>
</ul>
</li>
<li><p>The transformed output is written to <code>env_ci.g.dart</code></p>
</li>
</ol>
<p>This entire operation happens <strong>at build time</strong>:</p>
<ul>
<li><p>No secrets are committed</p>
</li>
<li><p>No secrets are logged</p>
</li>
<li><p>No secrets persist beyond the CI run</p>
</li>
</ul>
<h4 id="heading-7-success-feedback">7. Success Feedback</h4>
<pre><code class="language-java">echo "Generated config for $ENV_NAME"
</code></pre>
<p>This line provides a clear success signal in CI logs.</p>
<p>It answers three important questions instantly:</p>
<ul>
<li><p>Did the script run?</p>
</li>
<li><p>Did it finish successfully?</p>
</li>
<li><p>Which environment was generated?</p>
</li>
</ul>
<p>In long CI logs, these small confirmations matter.</p>
<p>Alright, now let's move on to the second script.</p>
<h3 id="heading-script-2-qualitygatesh">Script #2: <code>quality_gate.sh</code></h3>
<p>This script defines what <em>“good code”</em> means for your team.</p>
<pre><code class="language-yaml">#!/usr/bin/env bash
set -euo pipefail

echo "Running quality checks"

dart format --output=none --set-exit-if-changed .
flutter analyze
flutter test --no-pub --coverage

if command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1; then
  dart_code_metrics analyze lib --reporter=console || true
fi

echo "Quality checks passed"
</code></pre>
<p>Lets break down this script bit by bit.</p>
<h4 id="heading-1-start-amp-end-log-markers">1. Start &amp; End Log Markers</h4>
<pre><code class="language-yaml">echo "Running quality checks"
...
echo "Quality checks passed"
</code></pre>
<p>These two lines act as <strong>visual boundaries</strong> in CI logs.</p>
<p>In large pipelines (especially when Android and iOS jobs run in parallel), logs can be very noisy. Clear markers:</p>
<ul>
<li><p>Help developers quickly find the quality phase</p>
</li>
<li><p>Make debugging faster</p>
</li>
<li><p>Confirm that the script completed successfully</p>
</li>
</ul>
<p>The final success message only prints if <strong>everything above it passed</strong>, because <code>set -e</code> would have terminated the script earlier on failure.</p>
<p>So this line effectively means: All quality gates passed. Safe to proceed.</p>
<h4 id="heading-2-running-the-test-suite">2. Running the Test Suite</h4>
<pre><code class="language-yaml">flutter test --no-pub --coverage
</code></pre>
<p>This line executes your entire Flutter test suite.</p>
<p>Let’s break it down carefully.</p>
<p>1. <code>flutter test</code></p>
<p>This runs unit tests, widget tests, and any test under the <code>test/</code> directory. If <strong>any test fails</strong>, the command exits with a non-zero status code.</p>
<p>Because we enabled <code>set -e</code> earlier, that immediately stops the script and fails the CI job.</p>
<p>2. <code>--coverage</code></p>
<p>This flag generates a coverage report at:</p>
<pre><code class="language-yaml">coverage/lcov.info
</code></pre>
<p>This file can later be uploaded to Codecov, used to enforce minimum coverage thresholds, and tracked over time for quality improvement.</p>
<p>Even if you’re not enforcing coverage yet, generating it now future-proofs your pipeline.</p>
<h4 id="heading-3-optional-code-metrics">3. Optional Code Metrics</h4>
<pre><code class="language-yaml">if command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1; then
  dart_code_metrics analyze lib --reporter=console || true
fi
</code></pre>
<p>This block is intentionally designed to be optional and non-blocking.</p>
<p><strong>Step 1 – Check If the Tool Exists:</strong></p>
<pre><code class="language-yaml">command -v dart_code_metrics &gt;/dev/null 2&gt;&amp;1
</code></pre>
<p>This checks whether <code>dart_code_metrics</code> is installed.</p>
<ul>
<li><p>If installed, proceed</p>
</li>
<li><p>If not installed, skip silently</p>
</li>
</ul>
<p>The redirection:</p>
<ul>
<li><p><code>&gt;/dev/null</code> hides normal output</p>
</li>
<li><p><code>2&gt;&amp;1</code> hides errors</p>
</li>
</ul>
<p>This makes the script portable:</p>
<ul>
<li><p>Developers without the tool can still run the script</p>
</li>
<li><p>CI can enforce it if configured</p>
</li>
</ul>
<p><strong>Step 2 – Run Metrics (Soft Enforcement):</strong></p>
<pre><code class="language-yaml">dart_code_metrics analyze lib --reporter=console || true
</code></pre>
<p>This analyzes the <code>lib/</code> directory and prints results in the console.</p>
<p>The important part is:</p>
<pre><code class="language-yaml">|| true
</code></pre>
<p>Because we enabled <code>set -e</code>, any failing command would normally stop the script.</p>
<p>Adding <code>|| true</code> overrides that behavior:</p>
<ul>
<li><p>If metrics report issues,</p>
</li>
<li><p>The script continues,</p>
</li>
<li><p>CI does not fail.</p>
</li>
</ul>
<p>Why design it this way? Because metrics are often gradual improvements, technical debt indicators, or advisory rather than blocking.</p>
<p>You can later remove <code>|| true</code> to make metrics mandatory.</p>
<h4 id="heading-4-final-success-message"><strong>4. Final Success Message</strong></h4>
<pre><code class="language-yaml">echo "✅ Quality checks passed"
</code></pre>
<p>This line only executes if formatting passed, static analysis passed, and tests passed.</p>
<p>If you see this in CI logs, it means the branch has successfully cleared the quality gate. It’s your automated approval before deployment steps begin.</p>
<h4 id="heading-what-this-script-guarantees">What This Script Guarantees</h4>
<p>With this in place, every branch must satisfy:</p>
<ul>
<li><p>Clean formatting</p>
</li>
<li><p>No analyzer errors</p>
</li>
<li><p>Passing tests</p>
</li>
<li><p>(Optional) Healthy metrics</p>
</li>
</ul>
<p>That’s how you move from <strong>“We try to maintain quality”</strong> to <strong>“Quality is enforced automatically.”</strong></p>
<p>Alright, on to the third script.</p>
<h3 id="heading-script-3-uploadsymbolssh-sentry"><strong>Script #3:</strong> <code>upload_symbols.sh</code> <strong>(Sentry)</strong></h3>
<p>This script is responsible for uploading <strong>obfuscation debug symbols</strong> to Sentry so production crashes remain readable.</p>
<pre><code class="language-yaml">#!/usr/bin/env bash
set -euo pipefail

RELEASE=${1:-}

[ -z "$RELEASE" ] &amp;&amp; exit 2

if ! command -v sentry-cli &gt;/dev/null 2&gt;&amp;1; then
  exit 0
fi

sentry-cli releases new "$RELEASE" || true

sentry-cli upload-dif build/symbols || true

sentry-cli releases finalize "$RELEASE" || true

echo "✅ Symbols uploaded for release $RELEASE"
</code></pre>
<p>Let's go through it step by step.</p>
<h4 id="heading-1-reading-the-release-identifier">1. Reading the Release Identifier</h4>
<pre><code class="language-yaml">RELEASE=${1:-}
</code></pre>
<p>This reads the first positional argument passed to the script.</p>
<p>When you call the script in CI, it typically looks like:</p>
<pre><code class="language-yaml">./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
</code></pre>
<p>So <code>$1</code> becomes the short Git commit SHA.</p>
<p>Using <code>${1:-}</code> ensures:</p>
<ul>
<li><p>If no argument is passed, the variable becomes an empty string</p>
</li>
<li><p>The script does not crash due to <code>set -u</code></p>
</li>
</ul>
<p>This release value ties the uploaded symbols, deployed build, and crash reports all to the exact same commit. This linkage is critical for production debugging.</p>
<h4 id="heading-2-validating-the-release-argument">2. Validating the Release Argument</h4>
<pre><code class="language-yaml">[ -z "$RELEASE" ] &amp;&amp; exit 2
</code></pre>
<p>This is a compact validation check.</p>
<ul>
<li><p><code>-z</code> checks whether the string is empty</p>
</li>
<li><p>If it is empty → exit with status code 2</p>
</li>
</ul>
<p>Conventionally:</p>
<ul>
<li><p><code>0</code> = success</p>
</li>
<li><p><code>1+</code> = failure</p>
</li>
<li><p><code>2</code> = incorrect usage</p>
</li>
</ul>
<p>This prevents symbol uploads from running without a release identifier, which would break traceability in Sentry.</p>
<h4 id="heading-3-checking-if-sentry-cli-exists">3. Checking If <code>sentry-cli</code> Exists</h4>
<pre><code class="language-yaml">if ! command -v sentry-cli &gt;/dev/null 2&gt;&amp;1; then
  exit 0
fi
</code></pre>
<p>This block checks whether the <code>sentry-cli</code> tool is available in the environment.</p>
<p>What’s happening:</p>
<ul>
<li><p><code>command -v sentry-cli</code> checks if it exists</p>
</li>
<li><p><code>&gt;/dev/null 2&gt;&amp;1</code> suppresses all output</p>
</li>
<li><p><code>!</code> negates the condition</p>
</li>
</ul>
<p>So this reads as: <em>"If</em> <code>sentry-cli</code> <em>is NOT installed, exit successfully."</em></p>
<p>Why exit with <code>0</code> instead of failing?</p>
<p>Because not every environment needs symbol uploads. Also, dev builds may not install Sentry, and you don’t want CI to fail just because Sentry isn’t configured.</p>
<p>This makes symbol uploading <strong>environment-aware</strong> and <strong>optional</strong>.</p>
<p>Production environments can install <code>sentry-cli</code>, while dev environments skip it cleanly.</p>
<h4 id="heading-4-creating-a-new-release-in-sentry">4. Creating a New Release in Sentry</h4>
<pre><code class="language-yaml">sentry-cli releases new "$RELEASE" || true
</code></pre>
<p>This tells Sentry: “A new release exists with this version identifier.”</p>
<p>Even if the release already exists, the script continues because of:</p>
<pre><code class="language-yaml">|| true
</code></pre>
<p>This prevents the build from failing if:</p>
<ul>
<li><p>The release was already created</p>
</li>
<li><p>The command returns a non-critical error</p>
</li>
</ul>
<p>The goal is resilience, not strict enforcement.</p>
<h4 id="heading-5-uploading-debug-information-files-difs">5. Uploading Debug Information Files (DIFs)</h4>
<pre><code class="language-yaml">sentry-cli upload-dif build/symbols || true
</code></pre>
<p>This is the core step.</p>
<p><code>build/symbols</code> is generated when you build Flutter with:</p>
<pre><code class="language-yaml">--obfuscate --split-debug-info=build/symbols
</code></pre>
<p>When you obfuscate Flutter builds:</p>
<ul>
<li><p>Method names are renamed</p>
</li>
<li><p>Stack traces become unreadable</p>
</li>
</ul>
<p>The symbol files allow Sentry to reverse-map obfuscated stack traces and show readable crash reports.</p>
<p>Without this step, production crashes look like:</p>
<pre><code class="language-yaml">a.b.c.d (Unknown Source)
</code></pre>
<p>With this step, you get:</p>
<pre><code class="language-yaml">AuthRepository.login()
</code></pre>
<p>Again, <code>|| true</code> ensures the build doesn’t fail if:</p>
<ul>
<li><p>The directory doesn’t exist</p>
</li>
<li><p>No symbols were generated</p>
</li>
<li><p>Upload encounters a transient issue</p>
</li>
</ul>
<p>Symbol uploads should not block deployment.</p>
<h4 id="heading-6-finalizing-the-release">6. Finalizing the Release</h4>
<pre><code class="language-yaml">sentry-cli releases finalize "$RELEASE" || true
</code></pre>
<p>This marks the release as complete in Sentry.</p>
<p>Finalizing signals:</p>
<ul>
<li><p>The release is deployed</p>
</li>
<li><p>It can begin aggregating crash reports</p>
</li>
<li><p>It’s ready for production monitoring</p>
</li>
</ul>
<p>Like the previous steps, this is soft-failed with <code>|| true</code> to keep CI robust.</p>
<h4 id="heading-what-this-script-guarantees">What This Script Guarantees</h4>
<p>When everything is configured correctly:</p>
<ol>
<li><p>Production build is obfuscated</p>
</li>
<li><p>Debug symbols are generated</p>
</li>
<li><p>Symbols are uploaded to Sentry</p>
</li>
<li><p>Crashes map back to real source code</p>
</li>
<li><p>Release version matches commit SHA</p>
</li>
</ol>
<p>That’s production-grade crash observability.</p>
<p>Now that we've gone through the three helper scripts we've created to optimize and enhance this process, lets now dive into the three workflow .yaml files we're going to create.</p>
<h2 id="heading-workflow-1-prchecksyml">Workflow #1: <code>PR_CHECKS.YML</code></h2>
<p>This workflow will be designed to help ensure a certain standard is met once a PR is raised into a certain common or base branch. This will ensure that all quality checks in the incoming code pass before allowing any merge into the base branch.</p>
<p>This is basically a gate that verifies the quality of the code that's about to be merged into the base branch. If your pipeline allows unverified code into your base branch, then your CI becomes decorative, not protective.</p>
<p>Lets break down what's actually needed during every PR Check.</p>
<h3 id="heading-1-dependency-integrity">1. Dependency Integrity</h3>
<p>For Flutter apps, where we manage dependencies with the <strong>pub get</strong> command, it's important to make sure that the integrity of all dependencies are confirmed – up to date as well as compatible.</p>
<p>Every PR should begin with:</p>
<pre><code class="language-yaml">flutter pub get
</code></pre>
<p>This ensures:</p>
<ul>
<li><p><code>pubspec.yaml</code> is valid</p>
</li>
<li><p>Dependency constraints are consistent</p>
</li>
<li><p>Lockfiles are not broken</p>
</li>
<li><p>The project is buildable in a clean environment</p>
</li>
</ul>
<p>If this fails, the branch is not deployable.</p>
<h3 id="heading-2-static-analysis">2. Static Analysis</h3>
<p>This ensures code quality and architecture integrity. Static analysis helps prevent common issues like forgotten await, dead code, null safety violations, async misuse, and so on.</p>
<p>Most production bugs aren't business logic errors – they're structural carelessness. Static analysis helps enforce consistency automatically, so code reviews focus on intent, not linting.</p>
<pre><code class="language-yaml">flutter analyze --fatal-infos --fatal-warnings
</code></pre>
<h3 id="heading-3-formatting">3. Formatting</h3>
<p>This command ensures that your code is properly formatted based on your organization's coding standard and policies.</p>
<pre><code class="language-yaml">dart format --output=none --set-exit-if-changed .
</code></pre>
<h3 id="heading-4-tests">4. Tests</h3>
<p>This runs the unit, widget and business logic tests to ensure quality and avoid regression leaks, silent behavior changes and feature drift.</p>
<pre><code class="language-yaml">flutter test --coverage
</code></pre>
<h3 id="heading-5-test-coverage-enforcement">5. Test Coverage Enforcement</h3>
<p>Ideally, running tests is not enough. Your workflow should also enforce a minimum threshold:</p>
<pre><code class="language-yaml">if [ \((lcov --summary coverage/lcov.info | grep lines | awk '{print \)2}' | sed 's/%//') -lt 70 ]; then
  echo "Coverage too low"
  exit 1
fi
</code></pre>
<p>The command above ensures that a minimum test coverage of 70% is met, with this quality becomes measurable.</p>
<p>The five commands above must be checked (at least) for a <strong>quality gate</strong> to guarantee code quality, security, and integrity.</p>
<p>Now here is the full <strong>pr_checks.yml</strong> file:</p>
<pre><code class="language-yaml">name: PR Quality Gate

on:
  pull_request:
    branches: develop
    types: [opened, synchronize, reopened, ready_for_review]

jobs:
  pr-checks:
    name: Run quality checks on this pull request
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Setup Java
        uses: actions/setup-java@v1
        with:
          java-version: "12.x"

      - name: Setup Flutter
        uses: subosito/flutter-action@v1
        with:
          channel: "stable"

      - name: Install dependencies
        run: flutter pub get

      - name: Run quality checks
        run: ./scripts/quality_checks.sh

      - name: Notify Team (Success)
        if: success()
        run: |
          echo "PR Quality Checks PASSED"
          echo "PR: ${{ github.event.pull_request.html_url }}"
          echo "Branch: \({{ github.head_ref }} → \){{ github.base_ref }}"
          echo "By: @${{ github.actor }}"
          echo "Team notification: @foluwaseyi-dev @olabodegbolu"

      - name: Notify Team (Failure)
        if: failure()
        run: |
          echo "PR Quality Checks FAILED"
          echo "PR: ${{ github.event.pull_request.html_url }}"
          echo "Branch: \({{ github.head_ref }} → \){{ github.base_ref }}"
          echo "By: @${{ github.actor }}"
          echo "Please fix the issues before requesting review 🔧"
          echo "Team notification: @foluwaseyi-dev @olabodegbolu"
</code></pre>
<p>Every time a developer opens (or updates) a pull request targeting the <code>develop</code> branch, this workflow kicks in automatically. Think of it as a bouncer at the door: no code gets through without passing inspection first.</p>
<h3 id="heading-what-triggers-it">What Triggers it?</h3>
<p>The workflow fires on four events: when a PR is <code>opened</code>, <code>synchronized</code> (new commits pushed), <code>reopened</code>, or marked <code>ready_for_review</code>. So drafts won't trigger it – only PRs that are actually ready to be looked at.</p>
<h3 id="heading-what-does-it-actually-do">What Does it Actually Do?</h3>
<p>It spins up a fresh Ubuntu machine and runs five steps in sequence:</p>
<ol>
<li><p><strong>Checkout</strong>: pulls down the branch's code</p>
</li>
<li><p><strong>Setup Java 12</strong>: installs the JDK (likely a dependency for some tooling or build process)</p>
</li>
<li><p><strong>Setup Flutter (stable channel)</strong>: this is a Flutter project, so it grabs the stable Flutter SDK</p>
</li>
<li><p><strong>Install dependencies</strong>: runs <code>flutter pub get</code> to pull all Dart/Flutter packages</p>
</li>
<li><p><strong>Run quality checks</strong>: executes the helper shell script (<code>./scripts/quality_checks.sh</code>) that we created which runs linting, tests, formatting checks, or all of the above</p>
</li>
</ol>
<h3 id="heading-the-notification-layer">The Notification Layer</h3>
<p>After the checks run, the workflow reports the verdict and it's context-aware:</p>
<ul>
<li><p><strong>If everything passes</strong>, it logs a success message with the PR URL, branch info, and the person who opened it</p>
</li>
<li><p><strong>If something fails</strong>, it logs a failure message and nudges the author to fix issues before requesting a review</p>
</li>
</ul>
<p>Both outcomes tag <code>@foluwaseyi-dev</code> and <code>@olabodegbolu</code> – the two team members responsible for staying in the loop.</p>
<p>This workflow enforces a "fix it before you merge it" culture. No one can merge broken code into <code>develop</code> without the team knowing about it.</p>
<h2 id="heading-workflow-2-androidyml">Workflow #2: Android.yml</h2>
<p>It's a better practice to split your workflows based on platform. This helps you properly manage the instructions regarding each platform. This is the reason behind keeping the Android workflow separate.</p>
<p>Unlike <code>PR _Checks</code>, this workflow presumes that all checks for quality and standards have been done and the code that runs this workflow already meets the required standards.</p>
<p>Based on our predefined use case, let's create a workflow to handle test deployments when merged to develop or staging, and production level activities when merged to production.</p>
<pre><code class="language-yaml">name: Android Build &amp; Release

on:
  push:
    branches:
      - develop
      - staging
      - production

jobs:
  android:
    runs-on: ubuntu-latest
    env:
      FLUTTER_VERSION: 'stable'

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Java
        uses: actions/setup-java@v3
        with:
          distribution: 'temurin'
          java-version: '11'

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_VERSION }}

      - name: Install dependencies
        run: flutter pub get

      - name: Determine environment
        id: env
        run: |
          echo "branch=\({GITHUB_REF##*/}" &gt;&gt; \)GITHUB_OUTPUT
          if [ "${GITHUB_REF##*/}" = "develop" ]; then
            echo "ENV=dev" &gt;&gt; $GITHUB_OUTPUT
          elif [ "${GITHUB_REF##*/}" = "staging" ]; then
            echo "ENV=staging" &gt;&gt; $GITHUB_OUTPUT
          else
            echo "ENV=production" &gt;&gt; $GITHUB_OUTPUT
          fi

      # Dev uses hardcoded values no secrets needed
      - name: Generate config (dev)
        if: steps.env.outputs.ENV == 'dev'
        run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"

      # Staging and production inject real secrets
      - name: Generate config (staging/production)
        if: steps.env.outputs.ENV != 'dev'
        run: |
          if [ "${{ steps.env.outputs.ENV }}" = "staging" ]; then
            ./scripts/generate_config.sh staging \
              "${{ secrets.STAGING_BASE_URL }}" \
              "${{ secrets.STAGING_API_KEY }}"
          else
            ./scripts/generate_config.sh production \
              "${{ secrets.PROD_BASE_URL }}" \
              "${{ secrets.PROD_API_KEY }}"
          fi

      # Keystore is only needed for signed builds (staging &amp; production)
      - name: Restore Keystore
        if: steps.env.outputs.ENV != 'dev'
        run: |
          echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode &gt; android/app/upload-keystore.jks

      # Production builds are obfuscated + split debug info for Play Store
      - name: Build artifact
        run: |
          if [ "${{ steps.env.outputs.ENV }}" = "production" ]; then
            flutter build appbundle --release \
              --obfuscate \
              --split-debug-info=build/symbols
          else
            flutter build appbundle --release
          fi

      # Dev and staging go to Firebase App Distribution for internal testing
      - name: Upload to Firebase App Distribution
        if: steps.env.outputs.ENV == 'dev' || steps.env.outputs.ENV == 'staging'
        env:
          FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
          FIREBASE_ANDROID_APP_ID: ${{ secrets.FIREBASE_ANDROID_APP_ID }}
          FIREBASE_GROUPS: ${{ secrets.FIREBASE_GROUPS }}
        run: |
          firebase appdistribution:distribute \
            build/app/outputs/bundle/release/app-release.aab \
            --app "$FIREBASE_ANDROID_APP_ID" \
            --groups "$FIREBASE_GROUPS" \
            --token "$FIREBASE_TOKEN"

      # Only production goes to the Play Store
      - name: Upload to Play Store
        if: steps.env.outputs.ENV == 'production'
        uses: r0adkll/upload-google-play@v1
        with:
          serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
          packageName: com.your.package
          releaseFiles: build/app/outputs/bundle/release/app-release.aab
          track: production

      - name: Notify Team (Success)
        if: success()
        run: |
          echo "Android Build &amp; Release PASSED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"

      - name: Notify Team (Failure)
        if: failure()
        run: |
          echo "Android Build &amp; Release FAILED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"
          echo "Check the logs and fix the issue before retrying"
</code></pre>
<p>This workflow ensures that whenever code lands on the <strong>develop, staging or production</strong> branch, this action is triggered on a fresh Ubuntu machine.</p>
<p>This is triggered by a simple push to any of the tracked branches, no manual intervention needed.</p>
<p>Let's walk through it piece by piece.</p>
<h3 id="heading-1-the-setup-phase">1. The Setup Phase</h3>
<p>Before any Flutter-specific work happens, the workflow lays the foundation:</p>
<ol>
<li><p><strong>Checkout</strong>: grabs the latest code from the branch that triggered the run (using the more modern <code>actions/checkout@v3</code>).</p>
</li>
<li><p><strong>Java 11 via Temurin</strong>: this is an upgrade from the first workflow we created. Instead of a generic <code>setup-java@v1</code>, this uses the <code>temurin</code> distribution which is the Eclipse's open-source JDK build. It's the current industry standard for Android toolchains.</p>
</li>
<li><p><strong>Flutter (stable)</strong>: this pulls the stable Flutter SDK, version pinned via an environment variable (<code>FLUTTER_VERSION: 'stable'</code>) defined at the job level.</p>
</li>
<li><p><strong>Install dependencies</strong>: this ensures we run <code>flutter pub get</code> to pull all packages</p>
</li>
</ol>
<h3 id="heading-2-environment-detection">2. Environment Detection</h3>
<p>This is where it gets interesting. This workflow also checks and determines the environment which will help us define the next set of instructions to run.</p>
<p>This command reads the branch name from <strong>GITHUB REF</strong> and maps it to its environment label which we already created in one of our helper scripts.</p>
<ul>
<li><p>develop → ENV=dev</p>
</li>
<li><p>staging → ENV=staging</p>
</li>
<li><p>production → ENV=production</p>
</li>
</ul>
<p>It strips the branch name from the full ref path using <code>\({GITHUB_REF##*/}</code>, then writes both the branch name and the resolved <code>ENV</code> value to <code>\)GITHUB_OUTPUT</code>, making them available as named outputs (<code>steps.env.outputs.ENV</code>) for every subsequent step.</p>
<p>This means the rest of the pipeline can branch its behaviour based on which environment it's building for, different API keys, different signing configs, different targets – whatever the app needs.</p>
<h3 id="heading-3-config-injection">3. Config Injection</h3>
<p>With the environment resolved, the next step is injecting the right configuration into the app. This is where the <code>generate_config.sh</code> script we built earlier gets called directly from the workflow.</p>
<p>For the <code>dev</code> environment, hardcoded placeholder values are used. No real secrets are needed, since this build is only meant for internal developer testing:</p>
<pre><code class="language-yaml">- name: Generate config (dev)
  if: steps.env.outputs.ENV == 'dev'
  run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"
</code></pre>
<p>For staging and production, however, real secrets are pulled from GitHub Actions secrets and passed directly into the script:</p>
<pre><code class="language-yaml">- name: Generate config (staging/production)
  if: steps.env.outputs.ENV != 'dev'
  run: |
    if [ "${{ steps.env.outputs.ENV }}" = "staging" ]; then
      ./scripts/generate_config.sh staging \
        "${{ secrets.STAGING_BASE_URL }}" \
        "${{ secrets.STAGING_API_KEY }}"
    else
      ./scripts/generate_config.sh production \
        "${{ secrets.PROD_BASE_URL }}" \
        "${{ secrets.PROD_API_KEY }}"
    fi
</code></pre>
<p>Notice that these two steps use an <code>if</code> condition to make them mutually exclusive. Only one will ever run per job. This keeps the pipeline clean: no complicated branching logic inside the script itself, just a clear decision at the workflow level.</p>
<h3 id="heading-4-keystore-restoration">4. Keystore Restoration</h3>
<p>Android requires signed builds for distribution. The signing keystore file cannot be committed to the repository for obvious security reasons, so it's stored as a Base64-encoded GitHub secret and decoded at build time.</p>
<pre><code class="language-yaml">- name: Restore Keystore
  if: steps.env.outputs.ENV != 'dev'
  run: |
    echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 --decode &gt; android/app/upload-keystore.jks
</code></pre>
<p>This step is skipped entirely for the <code>dev</code> environment because dev builds are unsigned debug artifacts meant purely for internal testing on Firebase App Distribution. Only staging and production builds need to be properly signed.</p>
<p>To encode your keystore file as a Base64 string for storing in GitHub secrets, you have to run this locally:</p>
<pre><code class="language-yaml">base64 -i upload-keystore.jks | pbcopy
</code></pre>
<p>This copies the encoded string to your clipboard, which you can then paste directly into your GitHub repository secrets.</p>
<h3 id="heading-5-building-the-artifact">5. Building the Artifact</h3>
<p>With the environment configured and the keystore in place, the workflow builds the app bundle:</p>
<pre><code class="language-yaml">- name: Build artifact
  run: |
    if [ "${{ steps.env.outputs.ENV }}" = "production" ]; then
      flutter build appbundle --release \
        --obfuscate \
        --split-debug-info=build/symbols
    else
      flutter build appbundle --release
    fi
</code></pre>
<p>There's a deliberate difference between how production and non-production builds are compiled.</p>
<p>For production:</p>
<ul>
<li><p><code>--obfuscate</code> renames method and class names in the compiled output, making it significantly harder to reverse engineer the app</p>
</li>
<li><p><code>--split-debug-info=build/symbols</code> extracts the debug symbols into a separate directory at <code>build/symbols</code></p>
</li>
</ul>
<p>These symbols are what <code>upload_symbols.sh</code> later ships to Sentry, so obfuscated crash reports remain readable in your monitoring dashboard.</p>
<p>For dev and staging, neither flag is used. This keeps build times faster and makes local debugging easier since stack traces remain human-readable.</p>
<h3 id="heading-6-distributing-to-firebase-app-distribution">6. Distributing to Firebase App Distribution</h3>
<p>Once the app bundle is built, dev and staging builds are uploaded to Firebase App Distribution so testers can install them immediately:</p>
<pre><code class="language-yaml">- name: Upload to Firebase App Distribution
  if: steps.env.outputs.ENV == 'dev' || steps.env.outputs.ENV == 'staging'
  env:
    FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }}
    FIREBASE_ANDROID_APP_ID: ${{ secrets.FIREBASE_ANDROID_APP_ID }}
    FIREBASE_GROUPS: ${{ secrets.FIREBASE_GROUPS }}
  run: |
    firebase appdistribution:distribute \
      build/app/outputs/bundle/release/app-release.aab \
      --app "$FIREBASE_ANDROID_APP_ID" \
      --groups "$FIREBASE_GROUPS" \
      --token "$FIREBASE_TOKEN"
</code></pre>
<p>Three secrets power this step:</p>
<ul>
<li><p><code>FIREBASE_TOKEN</code>: the authentication token generated from <code>firebase login:ci</code></p>
</li>
<li><p><code>FIREBASE_ANDROID_APP_ID</code>: the app identifier from the Firebase console</p>
</li>
<li><p><code>FIREBASE_GROUPS</code>: the tester group(s) that should receive the build notification</p>
</li>
</ul>
<p>Once this step completes, every tester in the specified groups receives an email with a direct download link. No one needs to manually share an APK file over Slack or email.</p>
<h3 id="heading-7-deploying-to-the-play-store">7. Deploying to the Play Store</h3>
<p>Production builds skip Firebase entirely and goes straight to the Google Play Store:</p>
<pre><code class="language-yaml">- name: Upload to Play Store
  if: steps.env.outputs.ENV == 'production'
  uses: r0adkll/upload-google-play@v1
  with:
    serviceAccountJsonPlainText: ${{ secrets.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON }}
    packageName: com.your.package
    releaseFiles: build/app/outputs/bundle/release/app-release.aab
    track: production
</code></pre>
<p>This uses the <code>r0adkll/upload-google-play</code> GitHub Action, which handles the Google Play API interaction under the hood. The only requirements are:</p>
<ul>
<li><p>A Google Play service account with the correct permissions, stored as a JSON secret</p>
</li>
<li><p>The correct package name matching what is registered in your Play Console</p>
</li>
<li><p>The <code>track</code> set to <code>production</code> (you can also use <code>internal</code>, <code>alpha</code>, or <code>beta</code> depending on your release strategy)</p>
</li>
</ul>
<p>Replace <code>com.your.package</code> with your actual application ID (the same one defined in your <code>build.gradle</code> file).</p>
<h3 id="heading-8-the-notification-layer">8. The Notification Layer</h3>
<p>Just like the PR checks workflow, this workflow reports its outcome clearly:</p>
<pre><code class="language-yaml">- name: Notify Team (Success)
  if: success()
  run: |
    echo "Android Build &amp; Release PASSED"
    echo "Environment: ${{ steps.env.outputs.ENV }}"
    echo "Branch: ${{ steps.env.outputs.branch }}"
    echo "By: @${{ github.actor }}"
    echo "Commit: ${{ github.sha }}"

- name: Notify Team (Failure)
  if: failure()
  run: |
    echo "Android Build &amp; Release FAILED"
    echo "Environment: ${{ steps.env.outputs.ENV }}"
    echo "Branch: ${{ steps.env.outputs.branch }}"
    echo "By: @${{ github.actor }}"
    echo "Commit: ${{ github.sha }}"
    echo "Check the logs and fix the issue before retrying 🔧"
</code></pre>
<p>The success notification includes the environment, branch, actor, and shares everything needed to trace exactly what was deployed and who triggered it.</p>
<p>The failure notification includes the same context, with a clear call to action.</p>
<h2 id="heading-workflow-3-iosyml">Workflow #3: iOS.yml</h2>
<p>iOS CI/CD is more complex than Android by nature. This is because Apple's signing requirements involve certificates, provisioning profiles, and entitlements that all need to be in the right place before Xcode will produce a valid archive.</p>
<p>Fastlane helps us handles all of that complexity, and the workflow simply calls into it.</p>
<p>Here is the full <code>ios.yml</code>:</p>
<pre><code class="language-yaml">name: iOS Build &amp; Release

on:
  push:
    branches:
      - develop
      - staging
      - production

jobs:
  ios:
    runs-on: macos-latest
    env:
      FLUTTER_VERSION: 'stable'

    steps:
      - name: Checkout code
        uses: actions/checkout@v3

      - name: Setup Flutter
        uses: subosito/flutter-action@v2
        with:
          flutter-version: ${{ env.FLUTTER_VERSION }}

      - name: Install dependencies
        run: flutter pub get

      - name: Determine environment
        id: env
        run: |
          echo "branch=\({GITHUB_REF##*/}" &gt;&gt; \)GITHUB_OUTPUT
          if [ "${GITHUB_REF##*/}" = "develop" ]; then
            echo "ENV=dev" &gt;&gt; $GITHUB_OUTPUT
          elif [ "${GITHUB_REF##*/}" = "staging" ]; then
            echo "ENV=staging" &gt;&gt; $GITHUB_OUTPUT
          else
            echo "ENV=production" &gt;&gt; $GITHUB_OUTPUT
          fi

      - name: Generate config (dev)
        if: steps.env.outputs.ENV == 'dev'
        run: ./scripts/generate_config.sh dev "https://dev.api.example.com" "dev_dummy_key"

      - name: Generate config (staging/production)
        if: steps.env.outputs.ENV != 'dev'
        run: |
          if [ "${{ steps.env.outputs.ENV }}" = "staging" ]; then
            ./scripts/generate_config.sh staging \
              "${{ secrets.STAGING_BASE_URL }}" \
              "${{ secrets.STAGING_API_KEY }}"
          else
            ./scripts/generate_config.sh production \
              "${{ secrets.PROD_BASE_URL }}" \
              "${{ secrets.PROD_API_KEY }}"
          fi

      - name: Install Fastlane
        run: |
          cd ios
          gem install bundler
          bundle install

      - name: Import signing certificate
        if: steps.env.outputs.ENV != 'dev'
        run: |
          echo "${{ secrets.IOS_CERTIFICATE_BASE64 }}" | base64 --decode &gt; ios/cert.p12
          security create-keychain -p "" build.keychain
          security import ios/cert.p12 -k build.keychain -P "${{ secrets.IOS_CERTIFICATE_PASSWORD }}" -T /usr/bin/codesign
          security list-keychains -s build.keychain
          security default-keychain -s build.keychain
          security unlock-keychain -p "" build.keychain
          security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain

      - name: Install provisioning profile
        if: steps.env.outputs.ENV != 'dev'
        run: |
          echo "${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}" | base64 --decode &gt; profile.mobileprovision
          mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
          cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/

      - name: Build iOS (dev)
        if: steps.env.outputs.ENV == 'dev'
        run: flutter build ios --release --no-codesign

      - name: Build &amp; distribute to TestFlight (staging)
        if: steps.env.outputs.ENV == 'staging'
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
        run: |
          cd ios
          bundle exec fastlane beta

      - name: Build &amp; release to App Store (production)
        if: steps.env.outputs.ENV == 'production'
        env:
          APP_STORE_CONNECT_API_KEY_ID: ${{ secrets.APP_STORE_CONNECT_API_KEY_ID }}
          APP_STORE_CONNECT_API_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_API_ISSUER_ID }}
          APP_STORE_CONNECT_API_KEY_CONTENT: ${{ secrets.APP_STORE_CONNECT_API_KEY_CONTENT }}
        run: |
          cd ios
          bundle exec fastlane release

      - name: Upload Sentry symbols (production only)
        if: steps.env.outputs.ENV == 'production'
        env:
          SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
          SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
          SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
        run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)

      - name: Notify Team (Success)
        if: success()
        run: |
          echo "iOS Build &amp; Release PASSED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"

      - name: Notify Team (Failure)
        if: failure()
        run: |
          echo "iOS Build &amp; Release FAILED"
          echo "Environment: ${{ steps.env.outputs.ENV }}"
          echo "Branch: ${{ steps.env.outputs.branch }}"
          echo "By: @${{ github.actor }}"
          echo "Commit: ${{ github.sha }}"
          echo "Check the logs and fix the issue before retrying 🔧"
</code></pre>
<p>Let's walk through what is different about this workflow compared to that of android.</p>
<h3 id="heading-1-macos-runner">1. MacOS Runner</h3>
<pre><code class="language-yaml">runs-on: macos-latest
</code></pre>
<p>This is the major difference.</p>
<p>iOS builds require Xcode, which only runs on macOS. GitHub Actions provides hosted macOS runners, but they are significantly more expensive in terms of compute minutes than Ubuntu runners. Just keep that in mind when thinking about build frequency.</p>
<p>No Java setup is needed here. Flutter on iOS compiles through Xcode directly, so the toolchain requirements are different.</p>
<h3 id="heading-2-installing-fastlane">2. Installing Fastlane</h3>
<pre><code class="language-yaml">- name: Install Fastlane
  run: |
    cd ios
    gem install bundler
    bundle install
</code></pre>
<p>Fastlane is a Ruby-based automation tool that handles certificate management, building, and uploading to TestFlight and the App Store.</p>
<p>This step navigates into the <code>ios/</code> directory and installs Fastlane along with all its dependencies as defined in the project's <code>Gemfile</code>.</p>
<p>Your <code>ios/Gemfile</code> should look something like this:</p>
<pre><code class="language-ruby">source "https://rubygems.org"

gem "fastlane"
</code></pre>
<p>And your <code>ios/fastlane/Fastfile</code> should define at minimum two lanes: one for staging (TestFlight) and one for production (App Store):</p>
<pre><code class="language-ruby">default_platform(:ios)

platform :ios do
  lane :beta do
    build_app(scheme: "Runner", export_method: "app-store")
    upload_to_testflight(skip_waiting_for_build_processing: true)
  end

  lane :release do
    build_app(scheme: "Runner", export_method: "app-store")
    upload_to_app_store(force: true, skip_screenshots: true, skip_metadata: true)
  end
end
</code></pre>
<h3 id="heading-3-certificate-and-provisioning-profile-setup">3. Certificate and Provisioning Profile Setup</h3>
<p>This is the step that trips most teams up the first time. Apple's code signing requires two things to be present on the machine:</p>
<ol>
<li><p>The signing certificate (a <code>.p12</code> file)</p>
</li>
<li><p>The provisioning profile</p>
</li>
</ol>
<p>Both are stored as Base64-encoded GitHub secrets and restored at build time.</p>
<pre><code class="language-yaml">- name: Import signing certificate
  if: steps.env.outputs.ENV != 'dev'
  run: |
    echo "${{ secrets.IOS_CERTIFICATE_BASE64 }}" | base64 --decode &gt; ios/cert.p12
    security create-keychain -p "" build.keychain
    security import ios/cert.p12 -k build.keychain -P "${{ secrets.IOS_CERTIFICATE_PASSWORD }}" -T /usr/bin/codesign
    security list-keychains -s build.keychain
    security default-keychain -s build.keychain
    security unlock-keychain -p "" build.keychain
    security set-key-partition-list -S apple-tool:,apple: -s -k "" build.keychain
</code></pre>
<p>Breaking this down step by step:</p>
<ul>
<li><p>Decodes the Base64 certificate and write it to <code>cert.p12</code></p>
</li>
<li><p>Creates a temporary keychain called <code>build.keychain</code> with an empty password</p>
</li>
<li><p>Imports the certificate into that keychain, granting codesign access</p>
</li>
<li><p>Sets it as the default keychain so Xcode finds it automatically</p>
</li>
<li><p>Unlocks the keychain so it can be used non-interactively</p>
</li>
<li><p>Sets partition list to allow access without repeated prompts</p>
</li>
</ul>
<p>The provisioning profile step is simpler:</p>
<pre><code class="language-yaml">- name: Install provisioning profile
  if: steps.env.outputs.ENV != 'dev'
  run: |
    echo "${{ secrets.IOS_PROVISIONING_PROFILE_BASE64 }}" | base64 --decode &gt; profile.mobileprovision
    mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles
    cp profile.mobileprovision ~/Library/MobileDevice/Provisioning\ Profiles/
</code></pre>
<p>It decodes the profile and copies it into the exact directory where Xcode expects to find provisioning profiles on any macOS system.</p>
<p>To encode your certificate and profile locally, you can run these:</p>
<pre><code class="language-bash">base64 -i Certificates.p12 | pbcopy   # for the certificate
base64 -i YourApp.mobileprovision | pbcopy   # for the provisioning profile
</code></pre>
<h3 id="heading-4-building-for-each-environment">4. Building for Each Environment</h3>
<p>Dev builds skip signing entirely. They're built without code signing just to verify the project compiles correctly on a clean machine:</p>
<pre><code class="language-yaml">- name: Build iOS (dev)
  if: steps.env.outputs.ENV == 'dev'
  run: flutter build ios --release --no-codesign
</code></pre>
<p>Staging builds go through Fastlane's <code>beta</code> lane, which builds and uploads to TestFlight. Production builds go through Fastlane's <code>release</code> lane, which submits directly to App Store Connect.</p>
<p>Both staging and production steps consume the same three App Store Connect API key secrets: the key ID, the issuer ID, and the key content itself.</p>
<p>Fastlane uses these to authenticate with Apple's API without requiring a manual Apple ID login.</p>
<h3 id="heading-5-sentry-symbol-upload">5. Sentry Symbol Upload</h3>
<p>On production iOS builds, the <code>upload_symbols.sh</code> script runs after the build completes, passing the current short commit SHA as the release identifier:</p>
<pre><code class="language-yaml">- name: Upload Sentry symbols (production only)
  if: steps.env.outputs.ENV == 'production'
  env:
    SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
    SENTRY_ORG: ${{ secrets.SENTRY_ORG }}
    SENTRY_PROJECT: ${{ secrets.SENTRY_PROJECT }}
  run: ./scripts/upload_symbols.sh $(git rev-parse --short HEAD)
</code></pre>
<p>This is the same script explained earlier in the helper scripts section. It creates a Sentry release, uploads the debug information files, and finalizes the release. Any production crash from this point forward will map back to real, readable source code in your Sentry dashboard.</p>
<h2 id="heading-secrets-and-configuration-reference">Secrets and Configuration Reference</h2>
<p>For this entire pipeline to work, you need to configure the following secrets in your GitHub repository. Go to <strong>Settings → Secrets and variables → Actions → New repository secret</strong> to add each one.</p>
<p><strong>Shared (used across environments):</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>FIREBASE_TOKEN</code></td>
<td>Generated via <code>firebase login:ci</code> on your local machine</td>
</tr>
<tr>
<td><code>FIREBASE_ANDROID_APP_ID</code></td>
<td>Android app ID from your Firebase console</td>
</tr>
<tr>
<td><code>FIREBASE_GROUPS</code></td>
<td>Comma-separated tester group names in Firebase</td>
</tr>
<tr>
<td><code>SENTRY_AUTH_TOKEN</code></td>
<td>Auth token from your Sentry account settings</td>
</tr>
<tr>
<td><code>SENTRY_ORG</code></td>
<td>Your Sentry organization slug</td>
</tr>
<tr>
<td><code>SENTRY_PROJECT</code></td>
<td>Your Sentry project slug</td>
</tr>
</tbody></table>
<p><strong>Staging:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>STAGING_BASE_URL</code></td>
<td>Your staging API base URL</td>
</tr>
<tr>
<td><code>STAGING_API_KEY</code></td>
<td>Your staging API or encryption key</td>
</tr>
</tbody></table>
<p><strong>Production:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>PROD_BASE_URL</code></td>
<td>Your production API base URL</td>
</tr>
<tr>
<td><code>PROD_API_KEY</code></td>
<td>Your production API or encryption key</td>
</tr>
</tbody></table>
<p><strong>Android:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>ANDROID_KEYSTORE_BASE64</code></td>
<td>Base64-encoded <code>.jks</code> keystore file</td>
</tr>
<tr>
<td><code>GOOGLE_PLAY_SERVICE_ACCOUNT_JSON</code></td>
<td>Full JSON content of your Play Console service account</td>
</tr>
</tbody></table>
<p><strong>iOS:</strong></p>
<table>
<thead>
<tr>
<th>Secret</th>
<th>Description</th>
</tr>
</thead>
<tbody><tr>
<td><code>IOS_CERTIFICATE_BASE64</code></td>
<td>Base64-encoded <code>.p12</code> signing certificate</td>
</tr>
<tr>
<td><code>IOS_CERTIFICATE_PASSWORD</code></td>
<td>Password protecting the <code>.p12</code> file</td>
</tr>
<tr>
<td><code>IOS_PROVISIONING_PROFILE_BASE64</code></td>
<td>Base64-encoded <code>.mobileprovision</code> file</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_API_KEY_ID</code></td>
<td>Key ID from App Store Connect → Users &amp; Access → Keys</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_API_ISSUER_ID</code></td>
<td>Issuer ID from the same App Store Connect page</td>
</tr>
<tr>
<td><code>APP_STORE_CONNECT_API_KEY_CONTENT</code></td>
<td>The full content of the downloaded <code>.p8</code> key file</td>
</tr>
</tbody></table>
<p>None of these values should ever appear in your codebase. If any secret is accidentally committed, rotate it immediately.</p>
<h2 id="heading-end-to-end-flow">End-to-End Flow</h2>
<p>With all three workflows in place, here is exactly what happens from the moment a developer opens a pull request to the moment a user receives an update:</p>
<h3 id="heading-1-developer-opens-a-pr-into-develop">1. Developer Opens a PR into <code>develop</code></h3>
<p>The <code>pr_checks.yml</code> workflow fires. It runs formatting checks, static analysis, and the full test suite. If anything fails, the PR cannot be merged and the team is notified immediately. The developer fixes the issues and pushes again, which triggers a fresh run.</p>
<h3 id="heading-2-pr-is-approved-and-merged-into-develop">2. PR is Approved and Merged into <code>develop</code></h3>
<p>The <code>android.yml</code> and <code>ios.yml</code> workflows both fire on the push event. They detect the environment as <code>dev</code>, inject placeholder config, build unsigned artifacts, and upload them to Firebase App Distribution. Testers receive an email and can install the build on their devices within minutes – no one shared a file manually.</p>
<h3 id="heading-3-develop-is-merged-into-staging">3. <code>develop</code> is Merged into <code>staging</code></h3>
<p>Both platform workflows fire again. This time the environment resolves to <code>staging</code>. Real secrets are injected, builds are properly signed, and the artifacts go to Firebase App Distribution (Android) and TestFlight (iOS). QA begins testing the staging build against the staging API.</p>
<h3 id="heading-4-staging-is-merged-into-production">4. <code>staging</code> is merged into <code>production</code></h3>
<p>Both workflows fire one final time. Production secrets are injected, builds are obfuscated and signed, debug symbols are uploaded to Sentry, and the final artifacts are submitted to the Google Play Store and App Store Connect. The release goes live on Apple and Google's review timelines with no further human intervention required.</p>
<p>From that first PR to a production submission, not a single command was run manually.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>Building this pipeline is an upfront investment that pays off from the very first release cycle. What used to be a sequence of error-prone manual steps building locally, signing, uploading, switching configs, and hoping nothing was mixed up is now a fully automated, auditable, and repeatable process that runs the moment code moves between branches.</p>
<p>The architecture we built here does more than just automate builds. The PR quality gate enforces team standards consistently, so code review becomes a conversation about intent rather than a hunt for formatting issues. The environment-aware config injection eliminates an entire class of production incidents where staging keys made it into a live release. The Sentry symbol upload means your team can debug production crashes with full source visibility even from an obfuscated binary.</p>
<p>Every piece of this pipeline also runs locally. The helper scripts in the <code>scripts/</code> folder are plain Bash so you can call them from your terminal the same way CI calls them. This eliminates the frustrating cycle of pushing a commit just to test a pipeline change.</p>
<p>As your team grows, this foundation scales with you. You can extend the <code>pr_checks.yml</code> to enforce code coverage thresholds, add a performance benchmarking job, or introduce a dedicated security scanning step. You can extend the platform workflows to support multiple flavors, multiple Firebase projects, or staged rollouts on the Play Store. The architecture stays the same – you're just adding new steps to an already working system.</p>
<p>This ensures that standards are met, code quality remains high, you have a proper team structure, clear process and automated post development activities are in place – and at the end of the day, you'll have an optimized engineering approach that will help your team in so many ways.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How the Factory and Abstract Factory Design Patterns Work in Flutter ]]>
                </title>
                <description>
                    <![CDATA[ In software development, particularly object-oriented programming and design, object creation is a common task. And how you manage this process can impact your app's flexibility, scalability, and maintainability. Creational design patterns govern how... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-the-factory-and-abstract-factory-design-patterns-work-in-flutter/</link>
                <guid isPermaLink="false">6978f477116625d0304ed264</guid>
                
                    <category>
                        <![CDATA[ design patterns ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Factory Design Pattern ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile apps ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Developer ]]>
                    </category>
                
                    <category>
                        <![CDATA[ OOPS ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Object Oriented Programming ]]>
                    </category>
                
                    <category>
                        <![CDATA[ design principles ]]>
                    </category>
                
                    <category>
                        <![CDATA[ object oriented design ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Abstract Factory Patterns ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Tue, 27 Jan 2026 17:23:03 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1769533734673/8b5ad88a-13d2-4fec-969b-55fd854df5c1.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In software development, particularly object-oriented programming and design, object creation is a common task. And how you manage this process can impact your app's flexibility, scalability, and maintainability.</p>
<p>Creational design patterns govern how classes and objects are created in a systematic and scalable way. They provide blueprints for creating objects so you don't repeat code. They also keep your system consistent and makes your app easy to extend.</p>
<p>There are five major Creational Design patterns:</p>
<ol>
<li><p><strong>Singleton:</strong> Ensures a class has only one instance and provides a global point of access to it.</p>
</li>
<li><p><strong>Factory Method</strong>: Provides an interface for creating objects but lets subclasses decide which class to instantiate.</p>
</li>
<li><p><strong>Abstract Factory</strong>: Creates families of related objects without specifying their concrete classes.</p>
</li>
<li><p><strong>Builder</strong>: Allows you to construct complex objects step by step, separating construction from representation.</p>
</li>
<li><p><strong>Prototype</strong>: Creates new objects by cloning existing ones, rather than creating from scratch.</p>
</li>
</ol>
<p>Each of these patterns solves specific problems around object creation, depending on the complexity and scale of your application.</p>
<p>In this tutorial, I'll explain what Creational Design Patterns are and how they work. We'll focus on two primary patterns: the Factory and Abstract Factory patterns.</p>
<p>Many people mix these two up, so here we'll explore:</p>
<ol>
<li><p>How each pattern works</p>
</li>
<li><p>Practical examples in Flutter</p>
</li>
<li><p>Applications, best practices, and usage</p>
</li>
</ol>
<p>By the end, you'll understand when to use Factory, when to switch to Abstract Factory, and how to structure your Flutter apps for scalability and maintainability.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a class="post-section-overview" href="#heading-how-the-factory-pattern-works-in-flutter">How the Factory Pattern Works in Flutter</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-step-1-define-the-product-and-abstract-creator">Step 1: Define the Product and Abstract Creator</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-2-implement-concrete-products">Step 2: Implement Concrete Products</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-3-create-the-factory">Step 3: Create the Factory</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-4-use-the-factory">Step 4: Use the Factory</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-factory-pattern-for-security-checks">Factory Pattern for Security Checks</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-how-the-abstract-factory-pattern-works-in-flutter">How the Abstract Factory Pattern Works in Flutter</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-step-1-define-abstract-product-interfaces">Step 1: Define Abstract Product Interfaces</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-2-implement-platform-specific-products">Step 2: Implement Platform-Specific Products</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-3-define-the-abstract-factory-interface">Step 3: Define the Abstract Factory Interface</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-4-implement-platform-specific-factories">Step 4: Implement Platform Specific Factories</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-step-5-client-code-using-abstract-factory">Step 5: Client Code Using Abstract Factory</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before diving into this tutorial, you should have:</p>
<ul>
<li><p>a basic understanding of the Dart programming language</p>
</li>
<li><p>familiarity with Object-Oriented Programming (OOP) concepts (particularly classes, inheritance, and abstract classes)</p>
</li>
<li><p>basic knowledge of Flutter development (helpful but not required)</p>
</li>
<li><p>an understanding of interfaces and polymorphism</p>
</li>
<li><p>and experience creating and instantiating classes in Dart.</p>
</li>
</ul>
<h2 id="heading-how-the-factory-pattern-works-in-flutter">How the Factory Pattern Works in Flutter</h2>
<p>You'll typically use the Factory Pattern when you want to manage data sets that might be related, but only for a single type of object.</p>
<p>Let's say you want to manage themes for Android and iOS. Using the Factory Pattern allows you to encapsulate object creation and keep your app modular. We'll build this step by step so you can see how the pattern works.</p>
<h3 id="heading-step-1-define-the-product-and-abstract-creator">Step 1: Define the Product and Abstract Creator</h3>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AppTheme</span> </span>{
  <span class="hljs-built_in">String?</span> data;
  AppTheme({<span class="hljs-keyword">this</span>.data});
}

<span class="hljs-keyword">abstract</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ApplicationThemeData</span> </span>{
  Future&lt;AppTheme&gt; getApplicationTheme();
}
</code></pre>
<p>Here, <code>AppTheme</code> is a simple data class that holds theme information. This represents the product our factory will create. <code>ApplicationThemeData</code> serves as an abstract base class. This abstraction is crucial because it defines a contract that all concrete theme implementations must follow.</p>
<p>By requiring a <code>getApplicationTheme()</code> method, we ensure consistency across different platforms.</p>
<h3 id="heading-step-2-implement-concrete-products">Step 2: Implement Concrete Products</h3>
<p>Now we create platform-specific implementations that provide actual theme data.</p>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AndroidAppTheme</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">ApplicationThemeData</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;AppTheme&gt; getApplicationTheme() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> AppTheme(data: <span class="hljs-string">"Here is android theme"</span>);
  }
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">IOSThemeData</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">ApplicationThemeData</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;AppTheme&gt; getApplicationTheme() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> AppTheme(data: <span class="hljs-string">"This is IOS theme data"</span>);
  }
}
</code></pre>
<p>The concrete implementations, <code>AndroidAppTheme</code> and <code>IOSThemeData</code>, extend the abstract class and provide platform-specific theme data. Each returns an <code>AppTheme</code> object with content tailored to its respective platform.</p>
<h3 id="heading-step-3-create-the-factory">Step 3: Create the Factory</h3>
<p>The factory encapsulates the object creation logic, so client code doesn't need to know which specific theme class it's working with.</p>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ThemeFactory</span> </span>{
  ThemeFactory({<span class="hljs-keyword">required</span> <span class="hljs-keyword">this</span>.theme});
  ApplicationThemeData theme;

  loadTheme() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> theme.getApplicationTheme();
  }
}
</code></pre>
<p><code>ThemeFactory</code> acts as the factory itself. It accepts any <code>ApplicationThemeData</code> implementation and provides a unified <code>loadTheme()</code> method. This encapsulates the object creation logic cleanly.</p>
<h3 id="heading-step-4-use-the-factory">Step 4: Use the Factory</h3>
<p>Finally, we use the factory in our application code.</p>
<pre><code class="lang-dart">ThemeFactory(
  theme: Platform.isAndroid ? AndroidAppTheme() : IOSThemeData()
).loadTheme();
</code></pre>
<p>Here, you choose a theme (Android or iOS) and get the corresponding <code>AppTheme</code>. This approach is simple and effective when you only care about one functionality, like loading a theme.</p>
<p>The beauty of this pattern is that the client code remains clean and doesn't need to change if you add new platforms later.</p>
<h2 id="heading-factory-pattern-for-security-checks">Factory Pattern for Security Checks</h2>
<p>Another excellent use case for the Factory Pattern is when implementing security checks during your application bootstrap.</p>
<p>For instance, Android and iOS require different logic for internal security. Android might check for developer mode or rooted devices, while iOS checks for jailbroken devices. This scenario is a perfect example of when to apply the Factory Pattern, as it allows you to encapsulate platform-specific security logic cleanly and maintainably. Let's implement this step by step.</p>
<h3 id="heading-step-1-define-security-check-result-and-abstract-checker">Step 1: Define Security Check Result and Abstract Checker</h3>
<p>First, we need a standardized way to communicate security check outcomes and a contract for performing security checks.</p>
<pre><code class="lang-dart"><span class="hljs-comment">// Base security check result class</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">SecurityCheckResult</span> </span>{
  <span class="hljs-keyword">final</span> <span class="hljs-built_in">bool</span> isSecure;
  <span class="hljs-keyword">final</span> <span class="hljs-built_in">String</span> message;

  SecurityCheckResult({<span class="hljs-keyword">required</span> <span class="hljs-keyword">this</span>.isSecure, <span class="hljs-keyword">required</span> <span class="hljs-keyword">this</span>.message});
}

<span class="hljs-comment">// Abstract security checker</span>
<span class="hljs-keyword">abstract</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">SecurityChecker</span> </span>{
  Future&lt;SecurityCheckResult&gt; performSecurityCheck();
}
</code></pre>
<p>The <code>SecurityCheckResult</code> class provides a standardized way to communicate security check outcomes across platforms.</p>
<p>It contains a boolean flag indicating security status and a descriptive message for the user. The abstract <code>SecurityChecker</code> class defines the contract that all platform-specific security implementations must follow.</p>
<p>This ensures that, regardless of the platform, we can always call <code>performSecurityCheck()</code> and receive a consistent result type.</p>
<h3 id="heading-step-2-implement-platform-specific-security-checkers">Step 2: Implement Platform-Specific Security Checkers</h3>
<p>Now we create the actual security checking implementations for each platform.</p>
<pre><code class="lang-dart"><span class="hljs-comment">// Android-specific security implementation</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AndroidSecurityChecker</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">SecurityChecker</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;SecurityCheckResult&gt; performSecurityCheck() <span class="hljs-keyword">async</span> {
    <span class="hljs-built_in">bool</span> isRooted = <span class="hljs-keyword">await</span> checkIfDeviceIsRooted();
    <span class="hljs-keyword">if</span> (isRooted) {
      <span class="hljs-keyword">return</span> SecurityCheckResult(
        isSecure: <span class="hljs-keyword">false</span>,
        message: <span class="hljs-string">"Device is rooted. App cannot run on rooted devices."</span>
      );
    }

    <span class="hljs-built_in">bool</span> isDeveloperMode = <span class="hljs-keyword">await</span> checkDeveloperMode();
    <span class="hljs-keyword">if</span> (isDeveloperMode) {
      <span class="hljs-keyword">return</span> SecurityCheckResult(
        isSecure: <span class="hljs-keyword">false</span>,
        message: <span class="hljs-string">"Developer mode is enabled. Please disable it to continue."</span>
      );
    }

    <span class="hljs-keyword">return</span> SecurityCheckResult(
      isSecure: <span class="hljs-keyword">true</span>,
      message: <span class="hljs-string">"Device security check passed."</span>
    );
  }

  Future&lt;<span class="hljs-built_in">bool</span>&gt; checkIfDeviceIsRooted() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">false</span>; 
  }

  Future&lt;<span class="hljs-built_in">bool</span>&gt; checkDeveloperMode() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">false</span>; <span class="hljs-comment">// Placeholder</span>
  }
}

<span class="hljs-comment">// iOS-specific security implementation</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">IOSSecurityChecker</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">SecurityChecker</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;SecurityCheckResult&gt; performSecurityCheck() <span class="hljs-keyword">async</span> {
    <span class="hljs-built_in">bool</span> isJailbroken = <span class="hljs-keyword">await</span> checkIfDeviceIsJailbroken();

    <span class="hljs-keyword">if</span> (isJailbroken) {
      <span class="hljs-keyword">return</span> SecurityCheckResult(
        isSecure: <span class="hljs-keyword">false</span>,
        message: <span class="hljs-string">"Device is jailbroken. App cannot run on jailbroken devices."</span>
      );
    }

    <span class="hljs-keyword">return</span> SecurityCheckResult(
      isSecure: <span class="hljs-keyword">true</span>,
      message: <span class="hljs-string">"Device security check passed."</span>
    );
  }

  Future&lt;<span class="hljs-built_in">bool</span>&gt; checkIfDeviceIsJailbroken() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">false</span>; 
  }
}
</code></pre>
<p>The Android implementation focuses on detecting rooted devices and developer mode, which are common security concerns on Android.</p>
<p>A rooted device has elevated permissions that could allow malicious apps to access sensitive data, while developer mode can expose debugging interfaces.</p>
<p>The iOS implementation checks for jailbroken devices, which is the iOS equivalent of rooting. Jailbroken devices bypass Apple's security restrictions and can pose similar security risks.</p>
<h3 id="heading-step-3-create-the-security-factory">Step 3: Create the Security Factory</h3>
<p>The factory wraps the chosen security checker and provides a clean interface for running checks.</p>
<pre><code class="lang-dart"><span class="hljs-comment">// Security Factory</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">SecurityCheckFactory</span> </span>{
  SecurityCheckFactory({<span class="hljs-keyword">required</span> <span class="hljs-keyword">this</span>.checker});
  SecurityChecker checker;

  Future&lt;SecurityCheckResult&gt; runSecurityCheck() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">await</span> checker.performSecurityCheck();
  }
}
</code></pre>
<p>The <code>SecurityCheckFactory</code> provides a simple interface that accepts any <code>SecurityChecker</code> implementation. This means your app initialization code doesn't need to know about platform-specific security details – it just calls <code>runSecurityCheck()</code> and handles the result.</p>
<h3 id="heading-step-4-use-the-security-factory-in-app-bootstrap">Step 4: Use the Security Factory in App Bootstrap</h3>
<p>Finally, we integrate the security factory into our app's initialization process.</p>
<pre><code class="lang-dart"><span class="hljs-comment">// In your app's bootstrap/initialization</span>
Future&lt;<span class="hljs-keyword">void</span>&gt; initializeApp() <span class="hljs-keyword">async</span> {
  <span class="hljs-keyword">final</span> securityFactory = SecurityCheckFactory(
    checker: Platform.isAndroid 
      ? AndroidSecurityChecker() 
      : IOSSecurityChecker()
  );

  <span class="hljs-keyword">final</span> result = <span class="hljs-keyword">await</span> securityFactory.runSecurityCheck();

  <span class="hljs-keyword">if</span> (!result.isSecure) {
    <span class="hljs-comment">// Show error dialog and prevent app from continuing</span>
    showSecurityErrorDialog(result.message);
    <span class="hljs-keyword">return</span>;
  }

  <span class="hljs-comment">// Continue with normal app initialization</span>
  runApp(MyApp());
}
</code></pre>
<p>This usage example demonstrates how the Factory Pattern makes your app initialization code clean and maintainable.</p>
<p>The platform detection happens in one place, the factory handles the creation of the appropriate checker, and your code simply deals with the standardized result.</p>
<p><strong>Key takeaway:</strong> Factory is great when you need one type of object, but you want to abstract away the creation logic.</p>
<h2 id="heading-how-the-abstract-factory-pattern-works-in-flutter">How the Abstract Factory Pattern Works in Flutter</h2>
<p>The Abstract Factory Pattern comes into play when you have more than two data sets for comparison, and each set includes multiple functionalities.</p>
<p>For example, imagine you now want to manage themes, widgets, and architecture for Android, iOS, and Linux. Managing this with just a Factory becomes messy, so Abstract Factory provides a structured way to handle multiple related objects for different platforms.</p>
<p>So let's see how you can handle this using the abstract factory method.</p>
<h3 id="heading-step-1-define-abstract-product-interfaces">Step 1: Define Abstract Product Interfaces</h3>
<p>Before we dive into this implementation, it's important to understand what abstract product interfaces are. An abstract product interface is essentially a contract that defines what methods a product must implement, without specifying how they're implemented.</p>
<p>Think of it as a blueprint that ensures all related products share a common structure. In our case, we're defining three core functionalities that every platform must provide:</p>
<ol>
<li><p>Theme management</p>
</li>
<li><p>Widget handling</p>
</li>
<li><p>Architecture configuration.</p>
</li>
</ol>
<p>By creating these abstract interfaces first, we establish a consistent API that all platform-specific implementations will follow.</p>
<pre><code class="lang-dart"><span class="hljs-keyword">abstract</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ThemeManager</span> </span>{
  Future&lt;<span class="hljs-built_in">String</span>&gt; getTheme();
}

<span class="hljs-keyword">abstract</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">WidgetHandler</span> </span>{
  Future&lt;<span class="hljs-built_in">bool</span>&gt; getWidget();
}

<span class="hljs-keyword">abstract</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ArchitechtureHandler</span> </span>{
  Future&lt;<span class="hljs-built_in">String</span>&gt; getArchitechture();
}
</code></pre>
<p>Here, we’re defining three base functionalities that every platform will implement: theme, widgets, and architecture.</p>
<p>Each interface declares a single method that returns platform-specific information.</p>
<p>The <code>ThemeManager</code> retrieves theme data, <code>WidgetHandler</code> determines widget compatibility, and <code>ArchitechtureHandler</code> provides architecture details.</p>
<h3 id="heading-step-2-implement-platform-specific-products">Step 2: Implement Platform-Specific Products</h3>
<p>Now that we have our abstract interfaces defined, we need to create concrete implementations for each platform. This step is where we provide the actual, platform-specific behavior for each product type. Think of this as filling in the blueprint with real details.</p>
<p>While the abstract interfaces told us what methods we need, these concrete classes tell us how those methods behave on each specific platform. Each platform (Android, iOS, Linux) will have its own unique implementation of themes, widgets, and architecture.</p>
<h4 id="heading-android">Android:</h4>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AndroidThemeManager</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">ThemeManager</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;<span class="hljs-built_in">String</span>&gt; getTheme() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-string">"Android Theme"</span>;
  }
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AndroidWidgetHandler</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">WidgetHandler</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;<span class="hljs-built_in">bool</span>&gt; getWidget() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">true</span>;
  }
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AndroidArchitechtureHandler</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">ArchitechtureHandler</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;<span class="hljs-built_in">String</span>&gt; getArchitechture() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-string">"Android Architecture"</span>;
  }
}
</code></pre>
<p>For Android, we're creating three specific product classes. The <code>AndroidThemeManager</code> returns Material Design theme data, the <code>AndroidWidgetHandler</code> returns true to indicate that Android supports home screen widgets, and the <code>AndroidArchitechtureHandler</code> provides information about Android's architecture (which could include details about ARM, x86, or other processor architectures).</p>
<h4 id="heading-ios">iOS:</h4>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">IOSThemeManager</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">ThemeManager</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;<span class="hljs-built_in">String</span>&gt; getTheme() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-string">"IOS Theme"</span>;
  }
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">IOSWidgetHandler</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">WidgetHandler</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;<span class="hljs-built_in">bool</span>&gt; getWidget() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">false</span>;
  }
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">IOSArchitechtureHandler</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">ArchitechtureHandler</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;<span class="hljs-built_in">String</span>&gt; getArchitechture() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-string">"iOS Architecture"</span>;
  }
}
</code></pre>
<p>The iOS implementations follow the same structure but provide iOS-specific values. Notice that <code>IOSWidgetHandler</code> returns false, this could represent a scenario where certain widget features aren't available or behave differently on iOS compared to Android.</p>
<h4 id="heading-linux">Linux:</h4>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">LinuxThemeManager</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">ThemeManager</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;<span class="hljs-built_in">String</span>&gt; getTheme() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-string">"Linux Theme"</span>;
  }
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">LinuxWidgetHandler</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">WidgetHandler</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;<span class="hljs-built_in">bool</span>&gt; getWidget() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-keyword">true</span>;
  }
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">LinuxArchitechtureHandler</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">ArchitechtureHandler</span> </span>{
  <span class="hljs-meta">@override</span>
  Future&lt;<span class="hljs-built_in">String</span>&gt; getArchitechture() <span class="hljs-keyword">async</span> {
    <span class="hljs-keyword">return</span> <span class="hljs-string">"Linux Architecture"</span>;
  }
}
</code></pre>
<p>Similarly, Linux gets its own set of implementations, providing Linux-specific theme data and architecture information.</p>
<h3 id="heading-step-3-define-the-abstract-factory-interface">Step 3: Define the Abstract Factory Interface</h3>
<p>With our product classes ready, we now need to create the factory that will produce them.</p>
<p>The abstract factory interface is the master blueprint that declares which products our factory must be able to create. This interface doesn't create anything itself, it simply declares that any concrete factory must provide methods to create all three product types (theme, widget, and architecture handlers). This ensures that, regardless of which platform factory we use, we can always access all three functionalities.</p>
<pre><code class="lang-dart"><span class="hljs-keyword">abstract</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AppFactory</span> </span>{
  ThemeManager themeManager();
  WidgetHandler widgetManager();
  ArchitechtureHandler architechtureHandler();
}
</code></pre>
<p>Here, we define a factory blueprint. Any platform specific factory will have to implement all three functionalities. This guarantees consistency: every platform will have all three capabilities available.</p>
<h3 id="heading-step-4-implement-platform-specific-factories">Step 4: Implement Platform Specific Factories</h3>
<p>This is where everything comes together. We're now creating the actual factories that will produce the platform-specific products we defined earlier. Each factory is responsible for creating all the related products for its platform. The key advantage here is encapsulation: the factory knows how to create all the related objects for a platform, and it ensures they're compatible with each other. For example, <code>AndroidFactory</code> creates Android-specific theme managers, widget handlers, and architecture handlers that all work together seamlessly.</p>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AndroidFactory</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">AppFactory</span> </span>{
  <span class="hljs-meta">@override</span>
  ThemeManager themeManager() =&gt; AndroidThemeManager();

  <span class="hljs-meta">@override</span>
  WidgetHandler widgetManager() =&gt; AndroidWidgetHandler();

  <span class="hljs-meta">@override</span>
  ArchitechtureHandler architechtureHandler() =&gt; AndroidArchitechtureHandler();
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">IOSFactory</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">AppFactory</span> </span>{
  <span class="hljs-meta">@override</span>
  ThemeManager themeManager() =&gt; IOSThemeManager();

  <span class="hljs-meta">@override</span>
  WidgetHandler widgetManager() =&gt; IOSWidgetHandler();

  <span class="hljs-meta">@override</span>
  ArchitechtureHandler architechtureHandler() =&gt; IOSArchitechtureHandler();
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">LinuxFactory</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">AppFactory</span> </span>{
  <span class="hljs-meta">@override</span>
  ThemeManager themeManager() =&gt; LinuxThemeManager();

  <span class="hljs-meta">@override</span>
  WidgetHandler widgetManager() =&gt; LinuxWidgetHandler();

  <span class="hljs-meta">@override</span>
  ArchitechtureHandler architechtureHandler() =&gt; LinuxArchitechtureHandler();
}
</code></pre>
<p>Each concrete factory (AndroidFactory, IOSFactory, LinuxFactory) implements all three methods from the <code>AppFactory</code> interface. When you call <code>themeManager()</code> on <code>AndroidFactory</code>, you get an <code>AndroidThemeManager</code>. When you call it on <code>IOSFactory</code>, you get an <code>IOSThemeManager</code>. The same pattern applies to all products.</p>
<h3 id="heading-step-5-client-code-using-abstract-factory">Step 5: Client Code Using Abstract Factory</h3>
<p>Finally, we create the client code that uses our abstract factory. This is the layer that your application will actually interact with. The beauty of this pattern is that the client code doesn't need to know anything about the specific platform implementations, it just works with the abstract factory interface.</p>
<p>The <code>AppBaseFactory</code> class accepts any factory that implements <code>AppFactory</code> and provides a simple method to initialize all platform settings. The <code>CheckDevice</code> class determines which factory to use based on the current platform, completely abstracting this decision away from the rest of your application.</p>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AppBaseFactory</span> </span>{
  AppBaseFactory({<span class="hljs-keyword">required</span> <span class="hljs-keyword">this</span>.<span class="hljs-keyword">factory</span>});
  AppFactory <span class="hljs-keyword">factory</span>;

  getAppSettings() {
    <span class="hljs-keyword">factory</span>
      ..architechtureHandler()
      ..themeManager()
      ..widgetManager();
  }
}

<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">CheckDevice</span> </span>{
  <span class="hljs-keyword">static</span> <span class="hljs-keyword">get</span>() {
    <span class="hljs-keyword">if</span> (Platform.isAndroid) <span class="hljs-keyword">return</span> AndroidFactory();
    <span class="hljs-keyword">if</span> (Platform.isIOS) <span class="hljs-keyword">return</span> IOSFactory();
    <span class="hljs-keyword">if</span> (Platform.isLinux) <span class="hljs-keyword">return</span> LinuxFactory();
    <span class="hljs-keyword">throw</span> UnsupportedError(<span class="hljs-string">"Platform not supported"</span>);
  }
}

<span class="hljs-comment">// Usage</span>
AppBaseFactory(<span class="hljs-keyword">factory</span>: CheckDevice.<span class="hljs-keyword">get</span>()).getAppSettings();
</code></pre>
<p>Here's what's happening in this code:</p>
<p>The <code>AppBaseFactory</code> class acts as a wrapper around any <code>AppFactory</code> implementation. It provides a convenient <code>getAppSettings()</code> method that initializes all three components (architecture handler, theme manager, and widget manager) using Dart's cascade notation.</p>
<p>The <code>CheckDevice</code> class contains the platform detection logic. Its static <code>get()</code> method checks the current platform and returns the appropriate factory. This centralizes all platform detection in one place. When you call <code>AppBaseFactory(factory: CheckDevice.get()).getAppSettings()</code>, the code automatically detects your platform, creates the right factory, and initializes all platform-specific components, all without the calling code needing to know any platform-specific details.</p>
<p>Each platform factory produces all related products. The client only interacts with <code>AppBaseFactory</code>, remaining unaware of the internal implementation. This ensures your code is scalable, maintainable, and consistent.</p>
<h2 id="heading-real-world-application-payment-provider-management">Real-World Application: Payment Provider Management</h2>
<p>Another good use case for abstract factory is when you need to switch between multiple payment providers in your application and you only want to expose the necessary functionality to the client (presentation layer).</p>
<p>The abstract factory design pattern properly helps you manage this scenario in terms of concrete implementation, encapsulation, clean code, separation of concerns, and proper code structure and management. For example, you might support Stripe, PayPal, and Flutterwave in your application.</p>
<p>Each provider requires different initialization, transaction processing, and webhook handling. By using the Abstract Factory pattern, you can create a consistent interface for all payment operations while keeping provider-specific details encapsulated within their respective factory implementations.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>You should now feel more comfortable deciding when to use the Factory design pattern vs the Abstract Factory design pattern.</p>
<p>Understanding the factory and abstract factory patterns and their usages properly will help with object creation based on the particular use case you are trying to implement.</p>
<p>The Factory Pattern is ideal when you need one product and want to encapsulate creation logic while the Abstract Factory Pattern works well when you have multiple related products across platforms, need consistency, and want scalability. Using these patterns will help you write clean, maintainable, and scalable Flutter apps.</p>
<p>They give you a systematic approach to object creation and prevent messy, hard-to-maintain code as your app grows.</p>
 ]]>
                </content:encoded>
            </item>
        
            <item>
                <title>
                    <![CDATA[ How to Use the Singleton Design Pattern in Flutter: Lazy, Eager, and Factory Variations ]]>
                </title>
                <description>
                    <![CDATA[ In software engineering, sometimes you need only one instance of a class across your entire application. Creating multiple instances in such cases can lead to inconsistent behavior, wasted memory, or resource conflicts. The Singleton Design Pattern i... ]]>
                </description>
                <link>https://www.freecodecamp.org/news/how-to-use-the-singleton-design-pattern-in-flutter-lazy-eager-and-factory-variations/</link>
                <guid isPermaLink="false">69740b7bc3e68b8de44a179f</guid>
                
                    <category>
                        <![CDATA[ Singleton Design Pattern ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Object Oriented Programming ]]>
                    </category>
                
                    <category>
                        <![CDATA[ design patterns ]]>
                    </category>
                
                    <category>
                        <![CDATA[ ood ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Flutter ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Dart ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ software architecture ]]>
                    </category>
                
                    <category>
                        <![CDATA[ flutter development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Factory Design Pattern ]]>
                    </category>
                
                    <category>
                        <![CDATA[ Mobile Development ]]>
                    </category>
                
                    <category>
                        <![CDATA[ mobile app development ]]>
                    </category>
                
                <dc:creator>
                    <![CDATA[ Oluwaseyi Fatunmole ]]>
                </dc:creator>
                <pubDate>Fri, 23 Jan 2026 23:59:55 +0000</pubDate>
                <media:content url="https://cdn.hashnode.com/res/hashnode/image/upload/v1769212761076/11d41d2a-8efa-4ddb-9ee2-218f5be00d9f.png" medium="image" />
                <content:encoded>
                    <![CDATA[ <p>In software engineering, sometimes you need only one instance of a class across your entire application. Creating multiple instances in such cases can lead to inconsistent behavior, wasted memory, or resource conflicts.</p>
<p>The Singleton Design Pattern is a creational design pattern that solves this problem by ensuring that a class has exactly one instance and provides a global point of access to it.</p>
<p>This pattern is widely used in mobile apps, backend systems, and Flutter applications for managing shared resources such as:</p>
<ul>
<li><p>Database connections</p>
</li>
<li><p>API clients</p>
</li>
<li><p>Logging services</p>
</li>
<li><p>Application configuration</p>
</li>
<li><p>Security checks during app bootstrap</p>
</li>
</ul>
<p>In this article, we'll explore what the Singleton pattern is, how to implement it in Flutter/Dart, its variations (eager, lazy, and factory), and physical examples. By the end, you'll understand the proper way to use this pattern effectively and avoid common pitfalls.</p>
<h2 id="heading-table-of-contents">Table of Contents</h2>
<ol>
<li><p><a class="post-section-overview" href="#heading-prerequisites">Prerequisites</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-what-is-the-singleton-pattern">What is the Singleton Pattern?</a></p>
<ul>
<li><a class="post-section-overview" href="#heading-when-to-use-the-singleton-pattern">When to Use the Singleton Pattern</a></li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-how-to-create-a-singleton-class">How to Create a Singleton Class</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-eager-singleton">Eager Singleton</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-lazy-singleton">Lazy Singleton</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-choosing-between-eager-and-lazy">Choosing Between Eager and Lazy</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-factory-constructors-in-the-singleton-pattern">Factory Constructors in the Singleton Pattern</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-what-are-factory-constructors">What Are Factory Constructors?</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-implementing-singleton-with-factory-constructor">Implementing Singleton with Factory Constructor</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-when-not-to-use-a-singleton">When Not to Use a Singleton</a></p>
<ul>
<li><p><a class="post-section-overview" href="#heading-why-singletons-can-be-problematic">Why Singletons Can Be Problematic</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-scenarios-where-you-should-avoid-singletons">Scenarios Where You Should Avoid Singletons</a></p>
</li>
<li><p><a class="post-section-overview" href="#heading-general-guidelines">General Guidelines</a></p>
</li>
</ul>
</li>
<li><p><a class="post-section-overview" href="#heading-conclusion">Conclusion</a></p>
</li>
</ol>
<h2 id="heading-prerequisites">Prerequisites</h2>
<p>Before diving into this tutorial, you should have:</p>
<ol>
<li><p>Basic understanding of the Dart programming language</p>
</li>
<li><p>Familiarity with Object-Oriented Programming (OOP) concepts, particularly classes and constructors</p>
</li>
<li><p>Basic knowledge of Flutter development (helpful but not required)</p>
</li>
<li><p>Understanding of static variables and methods in Dart</p>
</li>
<li><p>Familiarity with the concept of class instantiation</p>
</li>
</ol>
<h2 id="heading-what-is-the-singleton-pattern">What is the Singleton Pattern?</h2>
<p>The Singleton pattern is a creational design pattern that ensures a class has only one instance and that there is a global point of access to the instance.</p>
<p>Again, this is especially powerful when managing shared resources across an application.</p>
<h3 id="heading-when-to-use-the-singleton-pattern">When to Use the Singleton Pattern</h3>
<p>You should use a Singleton when you are designing parts of your system that must exist once, such as:</p>
<ol>
<li><p>Global app state (user session, auth token, app config)</p>
</li>
<li><p>Shared services (logger, API client, database connection)</p>
</li>
<li><p>Resource heavy logic (encryption handlers, ML models, cache manager)</p>
</li>
<li><p>Application boot security (run platform-specific root/jailbreak checks)</p>
</li>
</ol>
<p>For example, in a Flutter app, Android may check developer mode or root status, while iOS checks jailbroken device state. A Singleton security class is a perfect way to enforce that these checks run once globally during app startup.</p>
<h2 id="heading-how-to-create-a-singleton-class">How to Create a Singleton Class</h2>
<p>We have two major ways of creating a singleton class:</p>
<ol>
<li><p>Eager Instantiation</p>
</li>
<li><p>Lazy Instantiation</p>
</li>
</ol>
<h3 id="heading-eager-singleton">Eager Singleton</h3>
<p>This is where the Singleton is created at load time, whether it's used or not.</p>
<p>In this case, the instance of the singleton class as well as any initialization logic runs at load time, regardless of when this class is actually needed or used. Here's how it works:</p>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">EagerSingleton</span> </span>{
  EagerSingleton._internal();
  <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> EagerSingleton _instance = EagerSingleton._internal();

  <span class="hljs-keyword">static</span> EagerSingleton <span class="hljs-keyword">get</span> instance =&gt; _instance;

  <span class="hljs-keyword">void</span> sayHello() =&gt; <span class="hljs-built_in">print</span>(<span class="hljs-string">"Hello from Eager Singleton"</span>);
}

<span class="hljs-comment">//usage</span>
<span class="hljs-keyword">void</span> main() {
  <span class="hljs-comment">// Accessing the singleton globally</span>
  EagerSingleton.instance.sayHello();
}
</code></pre>
<h4 id="heading-how-the-eager-singleton-works">How the Eager Singleton Works</h4>
<p>Let's break down what's happening in this implementation:</p>
<p>First, <code>EagerSingleton._internal()</code> is a private named constructor (notice the underscore prefix). This prevents external code from creating new instances using <code>EagerSingleton()</code>. The only way to get an instance is through the controlled mechanism we're about to define.</p>
<p>Next, <code>static final EagerSingleton _instance = EagerSingleton._internal();</code> is the key line. This creates the single instance immediately when the class is first loaded into memory. Because it's <code>static final</code>, it belongs to the class itself (not any particular instance) and can only be assigned once. The instance is created right here, at declaration time.</p>
<p>The <code>static EagerSingleton get instance =&gt; _instance;</code> getter provides global access to that single instance. Whenever you call <code>EagerSingleton.instance</code> anywhere in your code, you're getting the exact same object that was created when the class loaded.</p>
<p>Finally, <code>sayHello()</code> is just a regular method to demonstrate that the singleton works. You could replace this with any business logic your singleton needs to perform.</p>
<p>When you run the code in <code>main()</code>, the class loads, the instance is created immediately, and <code>EagerSingleton.instance.sayHello()</code> accesses that pre-created instance to call the method.</p>
<h4 id="heading-pros">Pros:</h4>
<ol>
<li><p>This is simple and thread safe, meaning it's not affected by concurrency, especially when your app runs on multithreads.</p>
</li>
<li><p>It's ideal if the instance is lightweight and may be accessed frequently.</p>
</li>
</ol>
<h4 id="heading-cons">Cons:</h4>
<ol>
<li>If this instance is never used through the runtime, it results in wasted memory and could impact application performance.</li>
</ol>
<h3 id="heading-lazy-singleton">Lazy Singleton</h3>
<p>In this case, the singleton instance is only created when the class is called or needed in runtime. Here, a trigger needs to happen before the instance is created. Let's see an example:</p>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">LazySingleton</span> </span>{
  LazySingleton._internal(); 
  <span class="hljs-keyword">static</span> LazySingleton? _instance;

  <span class="hljs-keyword">static</span> LazySingleton <span class="hljs-keyword">get</span> instance {
    _instance ??= LazySingleton._internal();
    <span class="hljs-keyword">return</span> _instance!;
  }

  <span class="hljs-keyword">void</span> sayHello() =&gt; <span class="hljs-built_in">print</span>(<span class="hljs-string">"Hello from LazySingleton"</span>);
}

<span class="hljs-comment">//usage </span>
<span class="hljs-keyword">void</span> main() {
  <span class="hljs-comment">// Accessing the singleton globally</span>
  LazySingleton.instance.sayHello();
}
</code></pre>
<h4 id="heading-how-the-lazy-singleton-works">How the Lazy Singleton Works</h4>
<p>The lazy implementation differs from eager in one crucial way: timing.</p>
<p>Again, <code>LazySingleton._internal()</code> is a private constructor that prevents external instantiation.</p>
<p>But notice that <code>static LazySingleton? _instance;</code> is declared as nullable and not initialized. Unlike the eager version, no instance is created at load time. The variable simply exists as <code>null</code> until it's needed.</p>
<p>The magic happens in the getter: <code>_instance ??= LazySingleton._internal();</code> uses Dart's null-aware assignment operator. This line says "if <code>_instance</code> is null, create a new instance and assign it. Otherwise, keep the existing one." This is the lazy initialization: the instance is only created the first time someone accesses it.</p>
<p>The first time you call <code>LazySingleton.instance</code>, <code>_instance</code> is null, so a new instance is created. Every subsequent call finds that <code>_instance</code> already exists, so it just returns that same instance.</p>
<p>The <code>return _instance!;</code> uses the null assertion operator because we know <code>_instance</code> will never be null at this point (we just ensured it's not null in the previous line).</p>
<p>This approach saves memory because if you never call <code>LazySingleton.instance</code> in your app, the instance never gets created.</p>
<h4 id="heading-pros-1">Pros:</h4>
<ol>
<li><p>Saves application memory, as it only creates what is needed in runtime.</p>
</li>
<li><p>Avoids memory leaks.</p>
</li>
<li><p>Is ideal for resource heavy objects while considering application performance.</p>
</li>
</ol>
<h4 id="heading-cons-1">Cons:</h4>
<ol>
<li>Could be difficult to manage in multithreaded environments, as you have to ensure thread safety while following this pattern.</li>
</ol>
<h3 id="heading-choosing-between-eager-and-lazy">Choosing Between Eager and Lazy</h3>
<p>Now that we've broken down these two major types of singleton instantiation, it's worthy of note that you'll need to be intentional while deciding whether to create a singleton the eager or lazy way. Your use case/context should help you determine what singleton pattern you need to apply during object creation.</p>
<p>As an engineer, you need to ask yourself these questions when using a singleton for object creation:</p>
<ol>
<li><p>Do I need this class instantiated when the app loads?</p>
</li>
<li><p>Based on the user journey, will this class always be needed during every session?</p>
</li>
<li><p>Can a user journey be completed without needing to call any logic in this class?</p>
</li>
</ol>
<p>These three questions will determine what pattern (eager or lazy) you should use to fulfill best practices while maintaining scalability and high performance in your application.</p>
<h2 id="heading-factory-constructors-in-the-singleton-pattern">Factory Constructors in the Singleton Pattern</h2>
<p>Applying factory constructors in the Singleton pattern can be powerful if you use them properly. But first, let's understand what factory constructors are.</p>
<h3 id="heading-what-are-factory-constructors">What Are Factory Constructors?</h3>
<p>A factory constructor in Dart is a special type of constructor that doesn't always create a new instance of its class. Unlike regular constructors that must return a new instance, factory constructors can:</p>
<ol>
<li><p>Return an existing instance (perfect for singletons)</p>
</li>
<li><p>Return a subclass instance</p>
</li>
<li><p>Apply logic before deciding what to return</p>
</li>
<li><p>Perform validation or initialization before returning an object</p>
</li>
</ol>
<p>The <code>factory</code> keyword tells Dart that this constructor has the flexibility to return any instance of the class (or its subtypes), not necessarily a fresh one.</p>
<h3 id="heading-implementing-singleton-with-factory-constructor">Implementing Singleton with Factory Constructor</h3>
<p>This allows you to apply initialization logic while your class instance is being created before returning the instance.</p>
<pre><code class="lang-dart"><span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">FactoryLazySingleton</span> </span>{
  FactoryLazySingleton._internal();
  <span class="hljs-keyword">static</span> <span class="hljs-keyword">final</span> FactoryLazySingleton _instance = FactoryLazySingleton._internal();

  <span class="hljs-keyword">static</span> FactoryLazySingleton <span class="hljs-keyword">get</span> instance =&gt; _instance;

  <span class="hljs-keyword">factory</span> FactoryLazySingleton() {
    <span class="hljs-comment">// Your logic runs here</span>
    <span class="hljs-built_in">print</span>(<span class="hljs-string">"Factory constructor called"</span>);
    <span class="hljs-keyword">return</span> _instance;
  }
}
</code></pre>
<h4 id="heading-how-the-factory-constructor-singleton-works">How the Factory Constructor Singleton Works</h4>
<p>This implementation combines aspects of both eager and lazy patterns with additional control.</p>
<p>The <code>FactoryLazySingleton._internal()</code> private constructor and <code>static final _instance</code> create an eager singleton. The instance is created immediately when the class loads.</p>
<p>The <code>static get instance</code> provides the traditional singleton access pattern we've seen before.</p>
<p>But the interesting part is the <code>factory FactoryLazySingleton()</code> constructor. This is a public constructor that looks like a normal constructor call, but behaves differently. When you call <code>FactoryLazySingleton()</code>, instead of creating a new instance, it runs whatever logic you've placed inside (in this case, a print statement), then returns the existing <code>_instance</code>.</p>
<p>This pattern is powerful because:</p>
<ol>
<li><p>You can log when someone tries to create an instance</p>
</li>
<li><p>You can validate conditions before returning the instance</p>
</li>
<li><p>You can apply configuration based on parameters passed to the factory</p>
</li>
<li><p>You can choose to return different singleton instances based on conditions</p>
</li>
</ol>
<p>For example, you might have different configuration singletons for development vs production:</p>
<pre><code class="lang-dart"><span class="hljs-keyword">factory</span> FactoryLazySingleton({<span class="hljs-built_in">bool</span> isProduction = <span class="hljs-keyword">false</span>}) {
  <span class="hljs-keyword">if</span> (isProduction) {
    <span class="hljs-comment">// Apply production configuration</span>
    _instance.configure(productionSettings);
  } <span class="hljs-keyword">else</span> {
    <span class="hljs-comment">// Apply development configuration</span>
    _instance.configure(devSettings);
  }
  <span class="hljs-keyword">return</span> _instance;
}
</code></pre>
<h4 id="heading-pros-2">Pros</h4>
<ol>
<li><p>You can add logic before returning an instance</p>
</li>
<li><p>You can cache or reuse the same object</p>
</li>
<li><p>You can dynamically return a subtype if needed</p>
</li>
<li><p>You avoid unnecessary instantiation</p>
</li>
<li><p>You can inject configuration or environment logic</p>
</li>
</ol>
<h4 id="heading-cons-2">Cons</h4>
<ol>
<li><p>Adds slight complexity compared to simple getter access</p>
</li>
<li><p>The factory constructor syntax might confuse developers unfamiliar with the pattern</p>
</li>
<li><p>If overused with complex logic, it can make debugging harder</p>
</li>
<li><p>Can create misleading code where <code>FactoryLazySingleton()</code> looks like it creates a new instance but doesn't</p>
</li>
</ol>
<h2 id="heading-when-not-to-use-a-singleton">When Not to Use a Singleton</h2>
<p>While singletons are powerful, they're not always the right solution. Understanding when to avoid them is just as important as knowing when to use them.</p>
<h3 id="heading-why-singletons-can-be-problematic">Why Singletons Can Be Problematic</h3>
<p>Singletons create global state, which can make your application harder to reason about and test. They introduce tight coupling between components that shouldn't necessarily know about each other, and they can make it difficult to isolate components for unit testing.</p>
<h3 id="heading-scenarios-where-you-should-avoid-singletons">Scenarios Where You Should Avoid Singletons</h3>
<p>Avoid using the Singleton pattern if:</p>
<h4 id="heading-you-need-multiple-independent-instances">You need multiple independent instances</h4>
<p>If different parts of your app need their own separate configurations or states, singletons force you into a one-size-fits-all approach.</p>
<p>For example, if you're building a multi-tenant application where each tenant needs isolated data, a singleton would cause data to bleed between tenants.</p>
<p><strong>Alternative</strong>: Use dependency injection to pass different instances to different parts of your app. Each component receives the specific instance it needs through its constructor or a service locator.</p>
<pre><code class="lang-dart"><span class="hljs-comment">// Instead of singleton</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">UserRepository</span> </span>{
  <span class="hljs-keyword">final</span> DatabaseConnection db;
  UserRepository(<span class="hljs-keyword">this</span>.db); 
}

<span class="hljs-comment">// Usage</span>
<span class="hljs-keyword">final</span> dbForTenantA = DatabaseConnection(tenantId: <span class="hljs-string">'A'</span>);
<span class="hljs-keyword">final</span> dbForTenantB = DatabaseConnection(tenantId: <span class="hljs-string">'B'</span>);
<span class="hljs-keyword">final</span> repoA = UserRepository(dbForTenantA);
<span class="hljs-keyword">final</span> repoB = UserRepository(dbForTenantB);
</code></pre>
<h4 id="heading-your-architecture-avoids-shared-global-state">Your architecture avoids shared global state</h4>
<p>Modern architectural patterns like BLoC, Provider, or Riverpod in Flutter specifically aim to avoid global mutable state. Singletons work against these patterns by reintroducing global state.</p>
<p><strong>Alternative</strong>: Use state management solutions designed for Flutter. Provider, Riverpod, BLoC, or GetX offer better ways to share data across your app while maintaining testability and avoiding tight coupling.</p>
<pre><code class="lang-dart"><span class="hljs-comment">// Using Provider instead of singleton</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">AppConfig</span> </span>{
  <span class="hljs-keyword">final</span> <span class="hljs-built_in">String</span> apiUrl;
  AppConfig(<span class="hljs-keyword">this</span>.apiUrl);
}

<span class="hljs-comment">// Provide it at the top level</span>
<span class="hljs-keyword">void</span> main() {
  runApp(
    Provider&lt;AppConfig&gt;(
      create: (_) =&gt; AppConfig(<span class="hljs-string">'https://api.example.com'</span>),
      child: MyApp(),
    ),
  );
}

<span class="hljs-comment">// Access it anywhere in the widget tree</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">MyWidget</span> <span class="hljs-keyword">extends</span> <span class="hljs-title">StatelessWidget</span> </span>{
  <span class="hljs-meta">@override</span>
  Widget build(BuildContext context) {
    <span class="hljs-keyword">final</span> config = Provider.of&lt;AppConfig&gt;(context);

  }
}
</code></pre>
<h4 id="heading-it-forces-tight-coupling-between-unrelated-classes">It forces tight coupling between unrelated classes</h4>
<p>When multiple unrelated classes depend on the same singleton, they become indirectly coupled. Changes to the singleton affect all these classes, making the codebase fragile and hard to refactor.</p>
<p><strong>Alternative</strong>: Use interfaces and dependency injection. Define what behavior you need through an interface, then inject implementations. This way, classes depend on abstractions, not concrete singletons.</p>
<pre><code class="lang-dart"><span class="hljs-comment">// Define an interface</span>
<span class="hljs-keyword">abstract</span> <span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">Logger</span> </span>{
  <span class="hljs-keyword">void</span> log(<span class="hljs-built_in">String</span> message);
}

<span class="hljs-comment">// Implementation</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">ConsoleLogger</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">Logger</span> </span>{
  <span class="hljs-meta">@override</span>
  <span class="hljs-keyword">void</span> log(<span class="hljs-built_in">String</span> message) =&gt; <span class="hljs-built_in">print</span>(message);
}

<span class="hljs-comment">// Classes depend on the interface, not a singleton</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">PaymentService</span> </span>{
  <span class="hljs-keyword">final</span> Logger logger;
  PaymentService(<span class="hljs-keyword">this</span>.logger);

  <span class="hljs-keyword">void</span> processPayment() {
    logger.log(<span class="hljs-string">'Processing payment'</span>);
  }
}

<span class="hljs-comment">// Easy to test with mock</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">MockLogger</span> <span class="hljs-keyword">implements</span> <span class="hljs-title">Logger</span> </span>{
  <span class="hljs-built_in">List</span>&lt;<span class="hljs-built_in">String</span>&gt; logs = [];
  <span class="hljs-meta">@override</span>
  <span class="hljs-keyword">void</span> log(<span class="hljs-built_in">String</span> message) =&gt; logs.add(message);
}
</code></pre>
<h4 id="heading-you-need-clean-isolated-testing">You need clean, isolated testing</h4>
<p>Singletons maintain state between tests, causing test pollution where one test affects another. This makes tests unreliable and order-dependent.</p>
<p><strong>Alternative</strong>: Use dependency injection and create fresh instances for each test. Most testing frameworks support this pattern, allowing you to inject mocks or fakes easily.</p>
<pre><code class="lang-dart"><span class="hljs-comment">// Testable code</span>
<span class="hljs-class"><span class="hljs-keyword">class</span> <span class="hljs-title">OrderService</span> </span>{
  <span class="hljs-keyword">final</span> PaymentProcessor processor;
  OrderService(<span class="hljs-keyword">this</span>.processor);
}

<span class="hljs-comment">// In tests</span>
<span class="hljs-keyword">void</span> main() {
  test(<span class="hljs-string">'processes order successfully'</span>, () {
    <span class="hljs-keyword">final</span> mockProcessor = MockPaymentProcessor();
    <span class="hljs-keyword">final</span> service = OrderService(mockProcessor); 

  });
}
</code></pre>
<h3 id="heading-general-guidelines">General Guidelines</h3>
<p>Use singletons sparingly and only when you truly need exactly one instance of something for the entire application lifecycle. Good candidates include logging systems, application-level configuration, and hardware interface managers.</p>
<p>For most other cases, prefer dependency injection, state management solutions, or simply passing instances where needed. These approaches make your code more flexible, testable, and maintainable.</p>
<h2 id="heading-conclusion">Conclusion</h2>
<p>The Singleton pattern is a powerful creational tool, but like every tool, you should use it strategically.</p>
<p>Overusing singletons can make apps tightly coupled, hard to test, and less maintainable.</p>
<p>But when used correctly, the Singleton pattern helps you save memory, enforce consistency, and control object lifecycle beautifully.</p>
<p>The key is understanding your specific use case and choosing the right implementation approach – whether eager, lazy, or factory-based – that best serves your application's needs while maintaining clean, testable code.</p>
 ]]>
                </content:encoded>
            </item>
        
    </channel>
</rss>
