<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Kirera paul murithi</title>
    <description>The latest articles on DEV Community by Kirera paul murithi (@paulmurithi).</description>
    <link>https://dev.to/paulmurithi</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F1242848%2Ffd1c82cd-f6d7-4a98-85df-e3983ce038c0.jpeg</url>
      <title>DEV Community: Kirera paul murithi</title>
      <link>https://dev.to/paulmurithi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/paulmurithi"/>
    <language>en</language>
    <item>
      <title>Teaching SokoFlow to Remember: Building a Conversational Engine with FSMs, Redis, and WhatsApp Webhooks</title>
      <dc:creator>Kirera paul murithi</dc:creator>
      <pubDate>Sun, 23 Aug 2026 09:20:52 +0000</pubDate>
      <link>https://dev.to/paulmurithi/teaching-sokoflow-to-remember-building-a-conversational-engine-with-fsms-redis-and-whatsapp-56mp</link>
      <guid>https://dev.to/paulmurithi/teaching-sokoflow-to-remember-building-a-conversational-engine-with-fsms-redis-and-whatsapp-56mp</guid>
      <description>&lt;h3&gt;
  
  
  SokoFlow Build Log — Month 3 of 4
&lt;/h3&gt;




&lt;p&gt;Welcome back. If you're new here — I'm an IT student running a structured, project-based learning plan toward becoming a production-grade backend engineer. Last semester I built &lt;a href="https://simpesa-docs.vercel.app/" rel="noopener noreferrer"&gt;SimPesa&lt;/a&gt;, a local-first M-Pesa STK Push simulator. This semester, the theme is &lt;em&gt;from controlled environments to the messy real world&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SokoFlow&lt;/strong&gt; is my flagship project: a conversational ERP for small Kenyan shopkeepers that lets them track inventory and record sales entirely through WhatsApp chat — no app, no training, just natural language.&lt;/p&gt;

&lt;p&gt;Month 1 was the business core, built with strict TDD. Month 2 was infrastructure — Docker, CI/CD, and a live staging deployment on Railway. If you haven't read those, I covered them in the previous build logs.&lt;/p&gt;

&lt;p&gt;Month 3 was the part I'd been building toward since the beginning: &lt;strong&gt;the Conversation Engine&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;At the end of the Month 1 blog, I left off with a question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;How do you turn messy human language into deterministic system actions without the whole thing becoming cursed spaghetti?&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The answer I committed to: &lt;strong&gt;Finite State Machines (FSMs) + controlled intent parsing + conversational context.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And then you might be wondering — why not just throw an LLM at this? That answer is coming. Keep reading.&lt;/p&gt;




&lt;h2&gt;
  
  
  Goals for Month 3
&lt;/h2&gt;

&lt;p&gt;The acceptance criteria going into this month were specific and measurable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Session survives a worker restart. An expired session resets to IDLE correctly.&lt;/li&gt;
&lt;li&gt;Full add-product flow completes via chat simulator in fewer than 5 messages.&lt;/li&gt;
&lt;li&gt;A sale is recorded correctly for an ambiguous product name (e.g., &lt;code&gt;"milk"&lt;/code&gt; → &lt;code&gt;"Milk 500ml"&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Simulator payload accepted. Invalid signature returns 401. Duplicate dropped silently.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Prerequisites: Two Mini-Projects Before the Real Work
&lt;/h2&gt;

&lt;p&gt;Before starting any FSM implementation, there were two self-contained tools I had to build first. These weren't part of the weekly milestones — they were infrastructure the rest of the month depended on. Both were interesting enough that I'll dedicate a separate post to each. For now, here's the short version.&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Chat Simulator
&lt;/h3&gt;

&lt;p&gt;Relying on a real WhatsApp API during local development would have been painful. So I built a &lt;strong&gt;CLI tool that mimics a WhatsApp conversation&lt;/strong&gt; directly from the terminal.&lt;/p&gt;

&lt;p&gt;After exploring a few options, I settled on an &lt;strong&gt;interactive REPL with a local webhook server&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The CLI step turns the simulator into a long-running process and spins up a lightweight background HTTP server using Python's built-in &lt;code&gt;http.server&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;The worker step: when Celery finishes processing a state transition, it fires a standard HTTP POST request back to that background server.&lt;/li&gt;
&lt;li&gt;The loop closes: the running CLI thread captures the inbound payload and prints it to the terminal instantly.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is a single terminal screen that behaves exactly like an active WhatsApp conversation — without needing a phone, a verified business account, or any real API calls.&lt;/p&gt;

&lt;h3&gt;
  
  
  2. Intent Resolver
&lt;/h3&gt;

&lt;p&gt;When a user's message hits the system, it's raw natural language. &lt;code&gt;"sold milk"&lt;/code&gt;, &lt;code&gt;"check stock"&lt;/code&gt;, &lt;code&gt;"Hi"&lt;/code&gt; — the system needs to decide what the user &lt;em&gt;wants&lt;/em&gt; before deciding what to &lt;em&gt;do&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;The &lt;strong&gt;Intent Resolver&lt;/strong&gt; sits in front of the FSM and classifies incoming text into a discrete intent the system can act on. Think of it as a receptionist at a building entrance. The receptionist doesn't solve your problem — they just say &lt;em&gt;"You're here for Finance"&lt;/em&gt; or &lt;em&gt;"You're here for HR"&lt;/em&gt; and point you to the right floor.&lt;/p&gt;

&lt;p&gt;The resolver maps something like &lt;code&gt;"Sale"&lt;/code&gt; into &lt;code&gt;Intent.RECORD_SALE&lt;/code&gt;. The FSM takes it from there.&lt;/p&gt;

&lt;p&gt;Crucially, the Intent Resolver is &lt;strong&gt;only active when the FSM is in the &lt;code&gt;IDLE&lt;/code&gt; state&lt;/strong&gt;. Once a user is inside a flow like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ADD_PRODUCT_NAME → ADD_PRODUCT_PRICE → ADD_PRODUCT_QTY
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;...the current state provides all the context needed. There's no ambiguity to resolve.&lt;/p&gt;




&lt;h2&gt;
  
  
  Week 9 — Redis Session Layer: FSM State Serialization, TTL Handling, and Dedup Keys
&lt;/h2&gt;

&lt;h3&gt;
  
  
  The Core Problem: HTTP Has No Memory
&lt;/h3&gt;

&lt;p&gt;The key mental model to start with: &lt;strong&gt;HTTP is stateless by design.&lt;/strong&gt; If a user sends a message to SokoFlow, the system processes it and responds. When the next message arrives seconds later, the system has no idea who this person is or what they were doing.&lt;/p&gt;

&lt;p&gt;For a back-and-forth conversation to work, the system needs persistent memory between requests. That points to a database — but not just any database. It needs to be fast and lightweight. Everything pointed to &lt;strong&gt;Redis&lt;/strong&gt;, an in-memory data store already in SokoFlow's stack as the Celery broker. No new dependencies, just a Redis client and a clear schema.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Session Schema: Pydantic vs. Dataclasses
&lt;/h3&gt;

&lt;p&gt;The first implementation decision was how to model the session structure. In Python, this immediately surfaces the Pydantic vs. dataclasses debate. They look similar on the surface — both package typed fields — but they serve fundamentally different purposes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dataclasses&lt;/strong&gt; are for clean internal data structures. &lt;strong&gt;Pydantic&lt;/strong&gt; is for enforcing schema integrity across untrusted boundaries.&lt;/p&gt;

&lt;p&gt;Redis stores raw strings. Celery workers read raw messages off a queue. These are untrusted boundaries. Pydantic wins here for four concrete reasons:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Reason&lt;/th&gt;
&lt;th&gt;Detail&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Automatic type coercion&lt;/td&gt;
&lt;td&gt;Converts raw string timestamps from Redis directly into &lt;code&gt;datetime&lt;/code&gt; objects — no manual parsing&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Nested deserialization&lt;/td&gt;
&lt;td&gt;Rebuilds complex nested JSON into deep Python objects automatically&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runtime validation&lt;/td&gt;
&lt;td&gt;Guarantees Celery workers never process corrupted or malformed session state — raises &lt;code&gt;ValidationError&lt;/code&gt; before business logic touches bad data&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Native JSON compatibility&lt;/td&gt;
&lt;td&gt;Serializes complex Python types directly into JSON strings Redis can store&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Handling Edge Cases from the Start
&lt;/h3&gt;

&lt;p&gt;Before writing the first handler, I defined how the system behaves when things go sideways. These aren't afterthoughts — they're first-class design decisions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;User Interruptions.&lt;/strong&gt; If a user sends &lt;code&gt;cancel&lt;/code&gt; or &lt;code&gt;menu&lt;/code&gt; from any non-IDLE state, the FSM transitions unconditionally to &lt;code&gt;IDLE&lt;/code&gt;, clears the working context, and confirms cancellation. This override works from &lt;em&gt;any&lt;/em&gt; state, no exceptions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Session Expiry.&lt;/strong&gt; When a session's TTL expires in Redis, the next message from that phone number finds nothing. The FSM initialises fresh from IDLE with: &lt;em&gt;"Your previous session timed out. Let's start fresh."&lt;/em&gt; Partial operations are never committed to PostgreSQL until the &lt;code&gt;CONFIRM&lt;/code&gt; state is explicitly reached.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Invalid Inputs.&lt;/strong&gt; Each state defines its own acceptance criteria. If a user enters a non-numeric value when a price is expected, the FSM holds the current state and re-prompts with a helpful message, incrementing an error counter. After 3 consecutive invalid inputs in the same state, the FSM transitions to IDLE with an apology — preventing infinite loops.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Duplicate Messages.&lt;/strong&gt; WhatsApp can deliver the same message multiple times under poor network conditions. Each incoming message is checked against a Redis set of recently processed message IDs (TTL: 60 seconds). If the &lt;code&gt;message_id&lt;/code&gt; already exists in the set, the request is acknowledged with &lt;code&gt;200 OK&lt;/code&gt; and silently dropped:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dedup:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The &lt;code&gt;NX&lt;/code&gt; flag makes this atomic — the key is set only if it doesn't already exist, which is exactly the check we need.&lt;/p&gt;

&lt;h3&gt;
  
  
  The ConversationStore: Abstracting Redis Away
&lt;/h3&gt;

&lt;p&gt;The &lt;code&gt;ConversationStore&lt;/code&gt; is the &lt;strong&gt;Data Access Object (DAO)&lt;/strong&gt; for session management. Its job is to completely hide Redis implementation details from the rest of the application. The FSM engine and Celery tasks don't care &lt;em&gt;how&lt;/em&gt; sessions are saved — they just call the store.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;get_session(phone)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Fetches raw string from Redis, parses JSON into a Pydantic model, returns &lt;code&gt;None&lt;/code&gt; on cache miss&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;save_session(...)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Updates session state using atomic compare-and-swap to prevent race conditions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;delete_session(phone)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Removes the key explicitly when a user completes a flow or cancels&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;save_session&lt;/code&gt; method is where things get interesting — and where the most important architectural decision of the week lives.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Race Condition Problem: Why &lt;code&gt;save_session&lt;/code&gt; Needed Special Treatment
&lt;/h3&gt;

&lt;p&gt;Anywhere a system reads state, modifies it in memory, and writes it back is dangerous. The pattern looks innocent:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Read state → modify in memory → write state back
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the state you read may no longer be the state that exists when you write. The classic failure mode is a &lt;strong&gt;lost update&lt;/strong&gt;: two workers read the same state simultaneously, both modify it independently, and the second write overwrites the first — producing a state that was never a valid transition.&lt;/p&gt;

&lt;p&gt;Imagine two requests arriving near-simultaneously from the same phone number:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Worker A reads state: IDLE
Worker B reads state: IDLE
Worker A transitions to ADD_PRODUCT_NAME in memory
Worker B transitions to ADD_PRODUCT_NAME in memory
Worker A writes ADD_PRODUCT_NAME → correct
Worker B writes ADD_PRODUCT_NAME → overwrites A's work
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;According to the FSM, the next valid state was &lt;code&gt;ADD_PRODUCT_PRICE&lt;/code&gt;, not a second &lt;code&gt;ADD_PRODUCT_NAME&lt;/code&gt;. The conversation is now corrupt.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Solution: Redis Lua Scripting (Optimistic CAS)
&lt;/h3&gt;

&lt;p&gt;I had two genuine options:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 1 — Optimistic CAS via Lua Script:&lt;/strong&gt; At write time, atomically verify that the state in Redis still matches what the worker expected. If another worker has already advanced the state, the write fails and the error is handled explicitly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Option 2 — Pessimistic Distributed Locking:&lt;/strong&gt; Acquire a Redis lock on the user's phone number at the &lt;em&gt;beginning&lt;/em&gt; of task processing, preventing any concurrent access.&lt;/p&gt;

&lt;p&gt;I chose Option 1. Here's why Option 2 was the wrong pick for this architecture:&lt;/p&gt;

&lt;p&gt;SokoFlow is built on the principle of a &lt;em&gt;dumb webhook receiver and a smart async worker pool&lt;/em&gt; (Principle 4 in the Developer Manifesto: "Failure Is a First-Class Input"). Pessimistic locking introduces a hard coordination problem between the lock TTL, the Celery task timeout, and the 30-minute Redis session TTL. If a worker crashes while holding a lock, that user's entire chat session is frozen until the lock expires — a terrible experience for an SME user who's in the middle of recording a sale.&lt;/p&gt;

&lt;p&gt;Optimistic CAS aligns with the architecture naturally. Concurrent messages from the same phone number are rare (the deduplication layer already handles retransmissions). When a CAS failure does occur, the recovery path is explicit: fetch the new state, log the conflict with a &lt;code&gt;correlation_id&lt;/code&gt;, and re-prompt the user — no blocked threads, no frozen sessions.&lt;/p&gt;

&lt;p&gt;The implementation uses a &lt;strong&gt;Lua script registered at startup&lt;/strong&gt; that executes atomically inside Redis:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;save_session(phone, expected_old_state, new_payload, ttl=1800) → bool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The Lua script receives three arguments: the expected old state, the new serialized session JSON, and the TTL. It compares the current state in Redis against the expected state before writing. The return codes map directly to typed domain exceptions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;1&lt;/code&gt; → Success&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;0&lt;/code&gt; → &lt;code&gt;StateMismatchError&lt;/code&gt; (stale state — another worker advanced it first)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;-1&lt;/code&gt; → &lt;code&gt;CorruptedSessionError&lt;/code&gt; (invalid JSON in Redis)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbry14mw5ix3ixusnyq7q.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbry14mw5ix3ixusnyq7q.png" alt="Session CAS flow" width="800" height="889"&gt;&lt;/a&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Week 10 — FSM Core: The ADD_PRODUCT Flow End-to-End
&lt;/h2&gt;

&lt;p&gt;With the session layer solid, Week 10 was about wiring up the actual state machine.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Architectural Strategy: State Pattern over if-else Chains
&lt;/h3&gt;

&lt;p&gt;The naive approach to routing a message to its handler looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;IDLE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ADD_PRODUCT_NAME&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;span class="k"&gt;elif&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="n"&gt;ADD_PRODUCT_PRICE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works until the FSM has 20 states. Then &lt;code&gt;process_message()&lt;/code&gt; becomes a 300-line method that knows everything. Adding, removing, or renaming a state means surgically editing one giant block.&lt;/p&gt;

&lt;p&gt;The better approach is a &lt;strong&gt;dispatch table&lt;/strong&gt; — a dictionary that maps each state directly to its handler function:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;handlers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;IDLE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;handle_idle&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ADD_PRODUCT_NAME&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;handle_product_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ADD_PRODUCT_PRICE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;handle_product_price&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;ADD_PRODUCT_QTY&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;handle_product_qty&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;CONFIRM_ADD_PRODUCT&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;handle_confirm_add_product&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;handlers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The engine now answers exactly one question: &lt;em&gt;which handler belongs to this state?&lt;/em&gt; Adding a new state means adding one line to the map and writing the handler independently. Each handler can evolve, be tested, and be reasoned about in complete isolation.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The dispatch table keeps the engine generic and the state-specific logic where it belongs: in the handler.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every message turn through the engine follows a strict pipeline:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Pre-processing (Universal Guards):&lt;/strong&gt; Check for global commands — &lt;code&gt;cancel&lt;/code&gt;, &lt;code&gt;menu&lt;/code&gt;, &lt;code&gt;exit&lt;/code&gt;, &lt;code&gt;stop&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State Evaluation:&lt;/strong&gt; Look up the current &lt;code&gt;SessionState&lt;/code&gt; in the dispatch table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Input Validation:&lt;/strong&gt; Validate the message according to that state's rules.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;State Transition &amp;amp; Context Mutation:&lt;/strong&gt; On valid input, update &lt;code&gt;UserSession.context&lt;/code&gt; and advance &lt;code&gt;UserSession.state&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error Counter Management:&lt;/strong&gt; On invalid input, increment &lt;code&gt;context.error_count&lt;/code&gt;. At 3 consecutive failures, force reset to IDLE.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atomic Persistence:&lt;/strong&gt; Save the updated session back to Redis via &lt;code&gt;ConversationStore.save_session()&lt;/code&gt;.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  The ADD_PRODUCT State Blueprint
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1y1as4iw7zw9vqs1fawu.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F1y1as4iw7zw9vqs1fawu.png" alt="ADD_PRODUCT FSM state transition diagram" width="800" height="1200"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Current State&lt;/th&gt;
&lt;th&gt;Input Accepted&lt;/th&gt;
&lt;th&gt;Target State&lt;/th&gt;
&lt;th&gt;System Response&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;IDLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Intent trigger (&lt;code&gt;"add product"&lt;/code&gt;, &lt;code&gt;"add"&lt;/code&gt;, &lt;code&gt;"new product"&lt;/code&gt;)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ADD_PRODUCT_NAME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;em&gt;"What is the product name? (e.g., Milk 500ml)"&lt;/em&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADD_PRODUCT_NAME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Non-empty string (2–100 chars)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ADD_PRODUCT_PRICE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stores name. &lt;em&gt;"What is the selling price in KES?"&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADD_PRODUCT_PRICE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Positive float/decimal (&amp;gt; 0)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ADD_PRODUCT_QTY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stores price. &lt;em&gt;"How many units in stock?"&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ADD_PRODUCT_QTY&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Positive integer (≥ 0)&lt;/td&gt;
&lt;td&gt;&lt;code&gt;CONFIRM_ADD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Stores qty. &lt;em&gt;"Confirm add: {name} at KES {price}, {qty} units? (yes/no)"&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CONFIRM_ADD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"yes"&lt;/code&gt; / &lt;code&gt;"y"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IDLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Commits product to Postgres, clears context, returns success&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;CONFIRM_ADD&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"no"&lt;/code&gt; / &lt;code&gt;"n"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ADD_PRODUCT_NAME&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Clears context, restarts from name prompt&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANY NON-IDLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;"cancel"&lt;/code&gt; / &lt;code&gt;"menu"&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IDLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Resets state, clears context, confirms cancellation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ANY NON-IDLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;3 consecutive invalid inputs&lt;/td&gt;
&lt;td&gt;&lt;code&gt;IDLE&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Resets state, apologises, sends guidance message&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Input Validators: Handling the Messy Reality of Kenyan SME Text
&lt;/h3&gt;

&lt;p&gt;Before wiring up the engine, I wrote pure, stateless helper functions to clean up the real-world text a shopkeeper would type:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;parse_product_name(raw_text)&lt;/code&gt; — trims whitespace, validates 2–100 character length.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;parse_price(raw_text)&lt;/code&gt; — handles Kenyan currency formats like &lt;code&gt;"KES 150"&lt;/code&gt;, &lt;code&gt;"150/="&lt;/code&gt;, and plain &lt;code&gt;"150"&lt;/code&gt;. Parses to &lt;code&gt;Decimal&lt;/code&gt; and validates greater than zero.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;parse_quantity(raw_text)&lt;/code&gt; — parses positive integers, validates ≥ 0.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;parse_confirmation(raw_text)&lt;/code&gt; — accepts positive triggers (&lt;code&gt;"yes"&lt;/code&gt;, &lt;code&gt;"y"&lt;/code&gt;, &lt;code&gt;"ndio"&lt;/code&gt;, &lt;code&gt;"1"&lt;/code&gt;) and negative triggers (&lt;code&gt;"no"&lt;/code&gt;, &lt;code&gt;"n"&lt;/code&gt;, &lt;code&gt;"zii"&lt;/code&gt;, &lt;code&gt;"2"&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are the entry points to every state transition. They raise typed &lt;code&gt;InvalidInputError&lt;/code&gt; with user-facing messages, so the FSM engine never has to write error messages itself — the validators do it.&lt;/p&gt;

&lt;h3&gt;
  
  
  A 5-Message Execution Flow, End-to-End
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;User:  "add product"
FSM:   IDLE → ADD_PRODUCT_NAME       | "Great, let's add a product. What is the product name?"

User:  "Uji Flour 2kg"
FSM:   ADD_PRODUCT_NAME → ADD_PRODUCT_PRICE  | "Nice. What is the price in KES?"

User:  "150/="
FSM:   ADD_PRODUCT_PRICE → ADD_PRODUCT_QTY  | "Got it. How many units are in stock?"

User:  "20"
FSM:   ADD_PRODUCT_QTY → CONFIRM_ADD | "Confirm: Uji Flour 2kg at KES 150.00, 20 units. Reply yes or no."

User:  "yes"
FSM:   CONFIRM_ADD → IDLE            | "Product added: Uji Flour 2kg at KES 150.00, opening qty 20."
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Biggest Lesson of Week 10: Process Separation and Database Sessions
&lt;/h3&gt;

&lt;p&gt;After completing the flow, I ran into a design question that tripped me up. When a user confirms a product, the data needs to move from Redis into PostgreSQL permanently. But here's the problem: &lt;strong&gt;Celery workers and the FastAPI web app are completely separate operating system processes&lt;/strong&gt; — often separate Docker containers in production. They cannot share memory, engines, or connection pools.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk64pctfse5zmfnasbqmx.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk64pctfse5zmfnasbqmx.png" alt="Process separation diagram: FastAPI web app vs. Celery worker, showing separate DB engines and session management" width="799" height="589"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In the FastAPI app, database sessions are managed through dependency injection — &lt;code&gt;Depends(get_db)&lt;/code&gt; yields a request-scoped session tied to the HTTP request lifecycle. Celery tasks are triggered by messages off the Redis queue, not HTTP requests. You can't use &lt;code&gt;Depends(get_db)&lt;/code&gt; inside a worker.&lt;/p&gt;

&lt;p&gt;The solution was a dedicated &lt;strong&gt;async context manager&lt;/strong&gt; for background tasks:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;async_session_factory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;async_sessionmaker&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;bind&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;engine&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;expire_on_commit&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;class_&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;AsyncSession&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="nd"&gt;@asynccontextmanager&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_worker_db&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;AsyncGenerator&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;AsyncSession&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;async_session_factory&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;commit&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="nb"&gt;Exception&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;session&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rollback&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;raise&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Inside any Celery task, wrapping execution in &lt;code&gt;async with get_worker_db() as db:&lt;/code&gt; gives a task-scoped transactional session. This wins on three fronts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No idle DB locks:&lt;/strong&gt; State transitions that don't persist (like &lt;code&gt;NAME → PRICE&lt;/code&gt;) open and close the session instantly.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Atomic commits:&lt;/strong&gt; The context manager automatically commits on clean exit, making product creation atomic at the &lt;code&gt;CONFIRM_ADD&lt;/code&gt; step.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Automatic rollback:&lt;/strong&gt; If PostgreSQL raises a constraint error or connection error mid-task, the rollback is guaranteed — no corrupted state.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Week 11 — RECORD_SALE and CHECK_STOCK Flows: Fuzzy Product Name Matching
&lt;/h2&gt;

&lt;p&gt;With &lt;code&gt;ADD_PRODUCT&lt;/code&gt; working end-to-end, Week 11 expanded the FSM to cover the two flows shopkeepers would use daily: recording a sale and checking stock.&lt;/p&gt;

&lt;p&gt;The wrinkle that made this interesting: &lt;strong&gt;users don't type exact product names.&lt;/strong&gt; They type &lt;code&gt;"milk"&lt;/code&gt;, &lt;code&gt;"mllk"&lt;/code&gt;, &lt;code&gt;"Fresh Milk"&lt;/code&gt;. The system needs to figure out what they actually meant.&lt;/p&gt;

&lt;h3&gt;
  
  
  What is Fuzzy Matching?
&lt;/h3&gt;

&lt;p&gt;Fuzzy matching is a technique for finding strings that are &lt;em&gt;approximately&lt;/em&gt; equal rather than exactly equal. A standard database index treats a one-letter typo as a completely different word. Fuzzy matching provides a mathematical way to say: &lt;em&gt;"These two strings aren't identical, but they're a 90% match."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;It's the same technology behind Google's &lt;em&gt;"Did you mean...?"&lt;/em&gt; feature and autocorrect.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Core Metric: Levenshtein Distance
&lt;/h4&gt;

&lt;p&gt;The most common distance measure is &lt;strong&gt;Levenshtein Distance&lt;/strong&gt; — the minimum number of single-character operations (insertion, deletion, substitution) needed to transform one word into another.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;cat&lt;/code&gt; → &lt;code&gt;cats&lt;/code&gt;: distance 1 (one insertion)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bread&lt;/code&gt; → &lt;code&gt;bred&lt;/code&gt;: distance 1 (one deletion)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;bred&lt;/code&gt; → &lt;code&gt;brad&lt;/code&gt;: distance 1 (one substitution)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Lower distance = closer match.&lt;/p&gt;

&lt;h4&gt;
  
  
  The Performance Problem
&lt;/h4&gt;

&lt;p&gt;Calculating Levenshtein distance against a full product table is expensive. On a table with 1,000 products, a naive query calculates the distance for every single row. A standard B-Tree index can't help here — it only understands exact alphabetical ordering. At scale, this would spike the database CPU immediately.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Solution: PostgreSQL pg_trgm and GIN Indexes
&lt;/h3&gt;

&lt;p&gt;Rather than introducing a separate fuzzy search library, I used what was already in the stack: &lt;strong&gt;PostgreSQL's &lt;code&gt;pg_trgm&lt;/code&gt; extension&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Instead of full-string comparison, &lt;code&gt;pg_trgm&lt;/code&gt; breaks strings into overlapping &lt;strong&gt;trigrams&lt;/strong&gt; — groups of three consecutive characters:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;"bread"&lt;/code&gt; → &lt;code&gt;b&lt;/code&gt;, &lt;code&gt;br&lt;/code&gt;, &lt;code&gt;bre&lt;/code&gt;, &lt;code&gt;rea&lt;/code&gt;, &lt;code&gt;ead&lt;/code&gt;, &lt;code&gt;ad&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;"bred"&lt;/code&gt; → &lt;code&gt;b&lt;/code&gt;, &lt;code&gt;br&lt;/code&gt;, &lt;code&gt;bre&lt;/code&gt;, &lt;code&gt;red&lt;/code&gt;, &lt;code&gt;ed&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The database measures similarity by counting &lt;em&gt;shared trigrams&lt;/em&gt;. A &lt;strong&gt;GIN (Generalized Inverted Index)&lt;/strong&gt; indexes these chunks, so PostgreSQL can skip 99% of the table and surface the closest matches in milliseconds.&lt;/p&gt;

&lt;p&gt;Why not a dedicated search library or service? Because the product catalogue for a small shop is not a Google-scale problem. Everything already lives in PostgreSQL. Adding &lt;code&gt;pg_trgm&lt;/code&gt; means zero new infrastructure, and the queries are fast enough for the domain.&lt;/p&gt;

&lt;h3&gt;
  
  
  Confidence-Based Routing
&lt;/h3&gt;

&lt;p&gt;When a user types a product name, the system returns similarity-scored matches and routes them based on confidence:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Score&lt;/th&gt;
&lt;th&gt;Action&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;≥ 0.8&lt;/td&gt;
&lt;td&gt;Auto-select product, proceed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;0.3 – 0.8&lt;/td&gt;
&lt;td&gt;Disambiguation: list up to 3 options — &lt;em&gt;"Did you mean: 1. Fresh Milk 500ml  2. Mala Milk 1L? Reply 1 or 2"&lt;/em&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&amp;lt; 0.3&lt;/td&gt;
&lt;td&gt;Re-prompt user, no match found&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This required one addition I hadn't planned for in the original FSM spec: a new &lt;code&gt;RECORD_SALE_PRODUCT_SELECTION&lt;/code&gt; state. When multiple candidates are returned, the FSM needs to pause and wait for the user's selection — it can't just advance to quantity collection. The candidates are stored in the session context, and the user's numeric response (&lt;code&gt;"1"&lt;/code&gt;, &lt;code&gt;"2"&lt;/code&gt;) is mapped to the correct product.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4jhpntl8vq7u37l0562y.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4jhpntl8vq7u37l0562y.png" alt="Fuzzy match confidence routing diagram: exact match → auto-select, ambiguous → disambiguation state, no match → re-prompt" width="800" height="1053"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  The RECORD_SALE Flow
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IDLE → RECORD_SALE_PRODUCT → [RECORD_SALE_PRODUCT_SELECTION] → RECORD_SALE_QTY → CONFIRM_SALE → IDLE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key business rules built into the flow:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stock validation guard.&lt;/strong&gt; Before transitioning to &lt;code&gt;CONFIRM_SALE&lt;/code&gt;, the system verifies &lt;code&gt;requested_qty &amp;lt;= available_stock&lt;/code&gt;. If insufficient stock exists, the FSM holds at &lt;code&gt;RECORD_SALE_QTY&lt;/code&gt; and warns the user — it never lets the sale proceed into confirmation with stock it doesn't have.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Atomic transaction execution.&lt;/strong&gt; On &lt;code&gt;"yes"&lt;/code&gt; at &lt;code&gt;CONFIRM_SALE&lt;/code&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Insert into the &lt;code&gt;sales&lt;/code&gt; table.&lt;/li&gt;
&lt;li&gt;Atomically decrement &lt;code&gt;inventory.quantity&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Check if remaining stock hits the low-stock threshold and queue a notification task if so.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The CHECK_STOCK Flow
&lt;/h3&gt;

&lt;p&gt;A lightweight 1–2 turn flow:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;IDLE → CHECK_STOCK_QUERY → IDLE
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fuzzy lookup → return current quantity and unit price → reset to IDLE. Simple, but uses the same trigram matching infrastructure as the sale flow.&lt;/p&gt;




&lt;h2&gt;
  
  
  Week 12 — WhatsApp Webhook: HMAC Verification, Payload Schema, and Deduplication
&lt;/h2&gt;

&lt;p&gt;The final week of Month 3 was about building the &lt;strong&gt;front door&lt;/strong&gt; — the point where the outside world starts talking to SokoFlow.&lt;/p&gt;

&lt;p&gt;Until now, messages entered through the internal chat simulator. Week 12 replaced the simulator with a production-shaped webhook that processes requests the way the real WhatsApp Business Platform would send them.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fubeckv50cyq9lt90vk0u.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fubeckv50cyq9lt90vk0u.png" alt="Webhook pipeline flow: WhatsApp HTTP POST → FastAPI endpoint → validate payload → verify HMAC → deduplicate → enqueue Celery task → return 200 OK" width="800" height="1137"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The core architectural principle for this week, pulled directly from the Developer Manifesto:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;"The Webhook Endpoint Is Dumb. The Worker Is Smart."&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The endpoint validates, deduplicates, enqueues, and responds. It does not touch the database. It does not make business decisions. That's the entire architectural thesis of Week 12.&lt;/p&gt;

&lt;h3&gt;
  
  
  Understanding HMAC: Authentication and Integrity
&lt;/h3&gt;

&lt;p&gt;A message arriving at the server doesn't prove it came from a trusted sender. A webhook endpoint is a publicly reachable URL — anyone who knows it can send requests.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;HMAC (Hash-based Message Authentication Code)&lt;/strong&gt; solves this with a shared secret known only to the sender and receiver:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;sender&lt;/strong&gt; combines the raw message body and the secret to produce a signature, which travels with the request.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;receiver&lt;/strong&gt; independently computes the expected signature from the same raw body and the same secret, then compares the two.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If the signatures match, two things are proven:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Authentication&lt;/strong&gt; — the request was created by someone who possesses the shared secret.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integrity&lt;/strong&gt; — the message body was not modified after it was signed.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;HMAC does &lt;em&gt;not&lt;/em&gt; prove freshness. Replay protection requires additional mechanisms — timestamps and event IDs — layered on top.&lt;/p&gt;

&lt;p&gt;The mental model: &lt;strong&gt;HMAC is a mathematical secret handshake between two systems.&lt;/strong&gt; The secret stays private. Only the resulting signature travels over the wire.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why the Raw Bytes Matter
&lt;/h3&gt;

&lt;p&gt;One implementation detail with real consequences: &lt;strong&gt;signature verification must happen against the raw request body bytes — before any JSON parsing.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Parsing the JSON and then re-serializing it can silently alter whitespace, key ordering, or escape sequences. The re-serialized body produces a different HMAC digest even though the data appears identical. Signature mismatch → 401 → dropped message.&lt;/p&gt;

&lt;p&gt;The verification step uses &lt;code&gt;hmac.compare_digest()&lt;/code&gt; rather than a plain string comparison. This provides constant-time comparison, which eliminates timing-based side-channel attacks.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;No valid signature → no entry. No exceptions.&lt;/strong&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Understanding the WhatsApp Webhook Payload
&lt;/h3&gt;

&lt;p&gt;The WhatsApp Business Platform sends a specific JSON envelope for inbound messages. The key components SokoFlow cares about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;messaging_product&lt;/code&gt;&lt;/strong&gt;: Always &lt;code&gt;"whatsapp"&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;metadata&lt;/code&gt;&lt;/strong&gt;: Business identification — &lt;code&gt;display_phone_number&lt;/code&gt; and &lt;code&gt;phone_number_id&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;contacts&lt;/code&gt;&lt;/strong&gt;: Sender profile — &lt;code&gt;profile.name&lt;/code&gt; and &lt;code&gt;wa_id&lt;/code&gt; (the sender's phone number).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;messages&lt;/code&gt;&lt;/strong&gt;: The actual inbound message content — &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;text.body&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;statuses&lt;/code&gt;&lt;/strong&gt;: Delivery receipt tracking (sent, delivered, read, failed) — ignored by SokoFlow's webhook since it only processes inbound messages.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The Pydantic contract models only what SokoFlow actually needs — not the entire WhatsApp universe. The goal was clean: feed the simulator payload through Pydantic and reliably extract &lt;code&gt;phone&lt;/code&gt;, &lt;code&gt;message_id&lt;/code&gt;, &lt;code&gt;message_text&lt;/code&gt;, and &lt;code&gt;message_type&lt;/code&gt; without the webhook knowing anything about FSM logic.&lt;/p&gt;

&lt;h3&gt;
  
  
  Redis Deduplication at the Webhook Layer
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3k4yefnfri5ydmuflzhy.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3k4yefnfri5ydmuflzhy.png" alt="Deduplication flow: incoming message → check Redis dedup set → exists? drop with 200 OK : process → add to dedup set with 60s TTL → enqueue" width="800" height="857"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;External networks are unreliable. The same webhook can arrive twice under normal operating conditions — retransmissions, network jitter, WhatsApp's own retry logic. The system must not record a sale twice because the same event showed up twice.&lt;/p&gt;

&lt;p&gt;The same &lt;code&gt;is_duplicate&lt;/code&gt; mechanism from Week 9 handles this at the webhook layer:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="c1"&gt;# SET key "1" only if it does NOT already exist (NX), with a 60-second TTL (EX)
&lt;/span&gt;&lt;span class="n"&gt;redis&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dedup:&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;message_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;nx&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ex&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;ttl&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Duplicate &lt;code&gt;message_id&lt;/code&gt; → return &lt;code&gt;200 OK&lt;/code&gt; with &lt;code&gt;status: ignored&lt;/code&gt; — no FSM processing, no Celery task dispatch.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;200 OK&lt;/code&gt; matters: returning an error code would signal to WhatsApp that the delivery failed, causing it to retry — which is exactly the cycle we're trying to prevent.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Complete Webhook Pipeline
&lt;/h3&gt;

&lt;p&gt;By the end of Week 12, a valid inbound message travels through this sequence:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;FastAPI receives the HTTP POST.&lt;/li&gt;
&lt;li&gt;Pydantic validates the payload shape. Malformed → 422.&lt;/li&gt;
&lt;li&gt;HMAC-SHA256 signature is verified against the raw body. Invalid → 401.&lt;/li&gt;
&lt;li&gt;Redis checks the &lt;code&gt;message_id&lt;/code&gt; for duplicates. Duplicate → 200 OK, dropped.&lt;/li&gt;
&lt;li&gt;The message is enqueued into Celery's &lt;code&gt;conversation_tasks&lt;/code&gt; queue.&lt;/li&gt;
&lt;li&gt;FastAPI returns 200 OK immediately.&lt;/li&gt;
&lt;li&gt;The Celery worker picks up the task, fetches or initialises the Redis session, runs the FSM, persists to PostgreSQL if needed, and sends the response back through the chat simulator loop.&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Progress Since Month 1
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Month 1&lt;/th&gt;
&lt;th&gt;Month 2&lt;/th&gt;
&lt;th&gt;Month 3&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Core business logic&lt;/td&gt;
&lt;td&gt;Automated workflows&lt;/td&gt;
&lt;td&gt;Conversational engine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local development&lt;/td&gt;
&lt;td&gt;Containerized development&lt;/td&gt;
&lt;td&gt;Stateful conversations&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual test runs&lt;/td&gt;
&lt;td&gt;CI pipeline&lt;/td&gt;
&lt;td&gt;Redis-backed sessions&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single synchronous process&lt;/td&gt;
&lt;td&gt;Background workers via Celery&lt;/td&gt;
&lt;td&gt;Finite state machine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs on my machine&lt;/td&gt;
&lt;td&gt;Runs in the cloud&lt;/td&gt;
&lt;td&gt;Simulated WhatsApp integration&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Final Thoughts and What's Next
&lt;/h2&gt;

&lt;p&gt;Month 3 is done. That's 12 weeks of building and shipping SokoFlow, and for the first time it actually &lt;em&gt;feels&lt;/em&gt; like a conversational system rather than just a backend with ambitions.&lt;/p&gt;

&lt;p&gt;The interesting engineering challenge this month wasn't the code — it was the &lt;strong&gt;design&lt;/strong&gt;. How do you impose deterministic structure on something as unpredictable as a human conversation? The answer turned out to be: you don't fight the unpredictability. You contain it. State machines give each message exactly one valid set of responses depending on where the conversation is. Redis gives the conversation memory. Lua scripting makes that memory safe under concurrency. Pydantic keeps every input honest before it touches business logic.&lt;/p&gt;

&lt;p&gt;And the reason I didn't use an LLM? That's a full answer worth its own section, and it's coming in the Month 4 wrap-up. The short version: an LLM would have been easier to build, but harder to operate, debug, and trust for a system that moves real money and inventory for real shop owners.&lt;/p&gt;

&lt;p&gt;Month 4 moves into &lt;strong&gt;async report generation, chaos testing, Swahili language support, and final integration&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The system can now have a conversation. Next month, I want to see how well it holds up when things go wrong.&lt;/p&gt;

&lt;p&gt;Stay locked in.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>Thinking Like a Platform Engineer: Celery, Docker, CI/CD, and Getting SokoFlow Into the Cloud</title>
      <dc:creator>Kirera paul murithi</dc:creator>
      <pubDate>Wed, 22 Jul 2026 13:50:18 +0000</pubDate>
      <link>https://dev.to/paulmurithi/thinking-like-a-platform-engineer-celery-docker-cicd-and-getting-sokoflow-into-the-cloud-109l</link>
      <guid>https://dev.to/paulmurithi/thinking-like-a-platform-engineer-celery-docker-cicd-and-getting-sokoflow-into-the-cloud-109l</guid>
      <description>&lt;h3&gt;
  
  
  SokoFlow Build Log — Month 2 of 4
&lt;/h3&gt;

&lt;p&gt;Welcome back to another SokoFlow build log. If you're new here — I'm an IT student running a structured, project-based learning plan to grow into a production-grade backend engineer. Last semester I built &lt;a href="https://simpesa-docs.vercel.app/" rel="noopener noreferrer"&gt;&lt;strong&gt;SimPesa&lt;/strong&gt;&lt;/a&gt;, a local-first STK Push simulator for testing M-Pesa Daraja payment workflows without touching a live sandbox. This semester, the theme is &lt;em&gt;from controlled environments to the messy real world&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;SokoFlow&lt;/strong&gt; is my flagship project for this semester: a conversational ERP for small Kenyan shopkeepers that lets them track inventory and record sales entirely through chat — no app to download, no onboarding session, just natural language over WhatsApp. I covered the architecture and core business logic in the &lt;a href="https://dev.to/paulmurithi/shifting-left-how-tdd-became-the-foundation-of-sokoflows-core-engine-485f"&gt;Month 1 build log&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;Month 2 wasn't about adding features. It was about making everything built in Month 1 deployable, scalable, and production-ready — so that every subsequent feature ships into an environment that actually mirrors the real world. That meant Celery, Docker, CI/CD, and a live staging deployment. Let's get into it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Where We Left Off
&lt;/h2&gt;

&lt;p&gt;Month 1 ended with a solid core: CRUD operations for the key models, business rules enforced through a strict TDD cycle, and a combined test suite of 40+ tests at 92% coverage. The foundation was sound — but it only ran on my machine.&lt;/p&gt;

&lt;p&gt;Month 2's goals were concrete and measurable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Celery processes a test job, with worker logs confirming execution.&lt;/li&gt;
&lt;li&gt;A green badge on the main branch; pull requests blocked if coverage drops below 85%.&lt;/li&gt;
&lt;li&gt;A production Dockerfile with build logs showing cache hits on unchanged layers.&lt;/li&gt;
&lt;li&gt;A CD pipeline that deploys to staging on merge to main, with both the Swagger UI and &lt;code&gt;/health&lt;/code&gt; endpoint publicly accessible.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  Week 5 — Celery Integration: Broker Config, Task Definition, Worker Startup
&lt;/h2&gt;

&lt;p&gt;Before writing a single line of code, I had to build the right mental model. This week revolved entirely around one idea: &lt;strong&gt;background jobs&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In Month 1, I built CRUD for sales. Now imagine a shopkeeper sends the system a message at the end of the day: &lt;em&gt;"Generate a daily sales report."&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The naive approach is to handle that inside the normal HTTP request-response cycle:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffj9pcsioe14paw2pads8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Ffj9pcsioe14paw2pads8.png" alt="Synchronous Report Generation" width="800" height="605"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The problems are immediate:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The API is blocked for the entire duration.&lt;/li&gt;
&lt;li&gt;The request is likely to time out.&lt;/li&gt;
&lt;li&gt;The server can't handle any other users while it waits.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The better pattern is to offload the heavy work to a &lt;strong&gt;background task&lt;/strong&gt;:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbrdznd30pqyem67aghna.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fbrdznd30pqyem67aghna.png" alt="Background Job Report Generation" width="800" height="746"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The user gets an immediate response. The hard work happens asynchronously. The API stays free to serve other requests.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where Each Component Fits
&lt;/h3&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Component&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Producer&lt;/strong&gt; (the API)&lt;/td&gt;
&lt;td&gt;Receives the job, adds it to the queue, responds immediately. It does not execute the work.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;strong&gt;Broker&lt;/strong&gt; (Redis / RabbitMQ)&lt;/td&gt;
&lt;td&gt;The mailroom. Stores tasks until a worker picks them up.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Queue&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The ordered line of waiting jobs inside the broker. First-in, first-out (with optional priority support).&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Worker&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;The process that actually executes jobs. You can scale workers independently to handle more load.&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Celery&lt;/strong&gt; is not the broker — it's the framework that ties everything together. It gives you a client (inside the API) to publish jobs, and a worker process to consume and execute them. The broker — Redis in SokoFlow's case — is the transport layer that carries messages between the two.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Mental Model That Cleared My Confusion
&lt;/h3&gt;

&lt;p&gt;Coming from SimPesa, I had worked extensively with BullMQ. The name similarity between BullMQ and RabbitMQ made me mentally group them as peers — which led to real confusion when setting Celery up.&lt;/p&gt;

&lt;p&gt;The hierarchy that finally cleared it up:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Application Layer      → Your business logic (generate_report)
Task Framework Layer   → BullMQ (Node.js) | Celery (Python)      ← peers
Messaging/Storage      → RabbitMQ | Redis | Amazon SQS           ← peers
Infrastructure         → TCP | Disk | Memory | Network
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;BullMQ and Celery sit at the same layer. RabbitMQ and Redis sit at the same layer. They are not interchangeable across layers.&lt;/p&gt;

&lt;h3&gt;
  
  
  Biggest Takeaway
&lt;/h3&gt;

&lt;p&gt;Offloading slow operations to background workers keeps the API fast and improves scalability — but it's not free. It introduces real complexity in error handling, state management, and debugging, because execution now happens entirely outside the main request flow. Design for that from the start.&lt;/p&gt;




&lt;h2&gt;
  
  
  Week 6 — GitHub Actions: Setting Up the CI Pipeline
&lt;/h2&gt;

&lt;p&gt;With Celery integrated, Week 6 was about automation. Specifically, making sure the test suite runs on every push and that a failing test physically blocks deployment.&lt;/p&gt;

&lt;h3&gt;
  
  
  Why CI Exists: The Problem It Solved
&lt;/h3&gt;

&lt;p&gt;To understand why CI pipelines matter, it helps to understand what software development looked like before them.&lt;/p&gt;

&lt;p&gt;Imagine a team of ten developers, each working on an isolated feature branch for several weeks. When everyone finally tries to merge their work at once — different assumptions, different dependencies, overlapping changes — the result is what engineers call &lt;strong&gt;Integration Hell&lt;/strong&gt;: a codebase that can barely compile, let alone pass tests.&lt;/p&gt;

&lt;p&gt;The breakthrough insight was deceptively simple: &lt;strong&gt;merge frequently&lt;/strong&gt;. If you merge three months of work at once, you inherit a thousand conflicts. Finding the root cause is nearly impossible. If you merge three hours of work, you get one or two conflicts. Root cause analysis takes minutes.&lt;/p&gt;

&lt;p&gt;But merging daily created a new bottleneck — human fatigue. Every merge meant someone had to manually compile the code, install dependencies, and run thousands of tests. Steps got skipped. Mistakes got missed.&lt;/p&gt;

&lt;p&gt;The solution was to automate the checklist entirely: &lt;strong&gt;CI pipelines&lt;/strong&gt;. Every push triggers an isolated server to compile the code, run the tests, and return a clear pass or fail signal. Jenkins, GitHub Actions, GitLab CI — they all do this, just with different configuration syntax.&lt;/p&gt;

&lt;h3&gt;
  
  
  Challenges
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Database dependencies in CI.&lt;/strong&gt; Locally, the test suite runs against a Postgres and Redis instance I already have configured. The CI runner starts with a completely clean machine. How does it get those?&lt;/p&gt;

&lt;p&gt;The modern answer is &lt;strong&gt;service containers&lt;/strong&gt;. When the CI job starts, the runner spins up lightweight Docker containers for Postgres and Redis alongside the main test container, connects them on a private local network, and exposes them via hostnames like &lt;code&gt;localhost&lt;/code&gt; or &lt;code&gt;postgres&lt;/code&gt;. The test code connects to them exactly the same way it does locally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Credentials and security.&lt;/strong&gt; Having database credentials hardcoded in a YAML file sounds alarming, but test database credentials carry essentially zero risk. The containers live on a private, ephemeral network that's unreachable from the public internet, and the database is permanently destroyed the moment the tests finish.&lt;/p&gt;

&lt;p&gt;Sensitive credentials — production deployment keys, third-party API keys — are a different matter entirely. Those go into the platform's encrypted secrets store (GitHub's "Secrets" settings panel), and the pipeline injects them into the runner's memory as environment variables at runtime.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Mental Model: GitHub Actions Runner = A Rented Computer
&lt;/h3&gt;

&lt;p&gt;The most useful reframe for understanding GitHub Actions:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Step&lt;/th&gt;
&lt;th&gt;On Your Laptop&lt;/th&gt;
&lt;th&gt;On the GitHub Runner&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Setup&lt;/td&gt;
&lt;td&gt;You open your laptop&lt;/td&gt;
&lt;td&gt;GitHub spins up a clean virtual machine&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Code&lt;/td&gt;
&lt;td&gt;You write code in VS Code&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;actions/checkout&lt;/code&gt; downloads your repo&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Tools&lt;/td&gt;
&lt;td&gt;You install Python and uv&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;setup-python&lt;/code&gt; and &lt;code&gt;setup-uv&lt;/code&gt; install them&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Infrastructure&lt;/td&gt;
&lt;td&gt;You start a database container&lt;/td&gt;
&lt;td&gt;The &lt;code&gt;services&lt;/code&gt; block starts one&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Execution&lt;/td&gt;
&lt;td&gt;You type &lt;code&gt;uv run pytest&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;The runner runs &lt;code&gt;uv run pytest&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cleanup&lt;/td&gt;
&lt;td&gt;You shut your laptop&lt;/td&gt;
&lt;td&gt;GitHub wipes the disk and destroys the VM&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The only real difference: your laptop persists its state. The runner is &lt;strong&gt;ephemeral&lt;/strong&gt; — it starts completely clean, does exactly what you scripted, and is destroyed the moment it finishes.&lt;/p&gt;

&lt;h3&gt;
  
  
  Biggest Takeaway
&lt;/h3&gt;

&lt;p&gt;A GitHub Actions runner is not abstract magic. It is a regular computer in a data center, running the same commands you would run manually in a terminal. Once that clicked, everything about configuring CI became far more intuitive.&lt;/p&gt;




&lt;h2&gt;
  
  
  Week 7 — Docker Image Optimization
&lt;/h2&gt;

&lt;p&gt;With CI in place, Week 7 was about containerization. My targets: build time under 90 seconds and a final image size under 200MB. Given the number of services in SokoFlow's stack, I was skeptical.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Problem With Unoptimized Images
&lt;/h3&gt;

&lt;p&gt;The typical first Dockerfile — pull a full OS, install every tool, copy all the code, run it — produces images that are 1–2 GB. That creates real operational problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;High infrastructure costs.&lt;/strong&gt; A team deploying 20 times a day and pushing a 2 GB image each time wastes enormous bandwidth and storage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Slow deployments.&lt;/strong&gt; Auto-scaling events that require pulling a 2 GB image are measurably slower than pulling a 100 MB one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expanded attack surface.&lt;/strong&gt; Bundling compilers, package managers, and text editors into a production image gives an attacker more tools to work with if they ever get in.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Solution 1: Multi-Stage Builds
&lt;/h3&gt;

&lt;p&gt;A multi-stage build is like a professional kitchen. You use the full counter space — heavy tools, cutting boards, prep mess — to build the dish. But when it's time to serve, only the final plate goes out. The kitchen stays in the kitchen.&lt;/p&gt;

&lt;p&gt;In Dockerfile terms:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Build stage:&lt;/strong&gt; Start with a heavy base image that includes compilers and package managers. Install all dependencies, compile what needs compiling.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Production stage:&lt;/strong&gt; Start fresh with a minimal base image (Alpine Linux is around 5 MB). Copy only the compiled application artifacts from the build stage. Discard everything else.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The result: a final image that might be 50–80 MB instead of 1 GB.&lt;/p&gt;

&lt;h3&gt;
  
  
  Solution 2: Respecting the Layer Cache
&lt;/h3&gt;

&lt;p&gt;Every instruction in a Dockerfile creates a new &lt;strong&gt;immutable layer&lt;/strong&gt;. Docker caches these layers and reuses them if their inputs haven't changed. The key implication: &lt;strong&gt;layer order determines cache efficiency&lt;/strong&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Layer 1 — Base image (rarely changes)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="s"&gt; python:3.12-alpine&lt;/span&gt;

&lt;span class="c"&gt;# Layer 2 — Dependencies (changes when requirements.txt changes)&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; requirements.txt .&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;pip &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; requirements.txt

&lt;span class="c"&gt;# Layer 3 — Application code (changes constantly)&lt;/span&gt;
&lt;span class="k"&gt;COPY&lt;/span&gt;&lt;span class="s"&gt; . .&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you only change application code, Docker rebuilds only Layer 3. Layers 1 and 2 are pulled straight from cache. Copy your code before your dependencies and you forfeit that optimization entirely — every code change forces a full dependency reinstall.&lt;/p&gt;

&lt;h3&gt;
  
  
  Challenges
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Chaining &lt;code&gt;RUN&lt;/code&gt; commands.&lt;/strong&gt; I noticed the recommended pattern for &lt;code&gt;apt-get&lt;/code&gt; installs chains everything into a single command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="c"&gt;# Three separate layers — the cleanup layer doesn't actually remove the cached package index&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; build-essential
&lt;span class="k"&gt;RUN &lt;/span&gt;&lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;

&lt;span class="c"&gt;# One layer — download, install, and cleanup happen before Docker takes the snapshot&lt;/span&gt;
&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    apt-get &lt;span class="nb"&gt;install&lt;/span&gt; &lt;span class="nt"&gt;-y&lt;/span&gt; &lt;span class="nt"&gt;--no-install-recommends&lt;/span&gt; build-essential libpq-dev &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="se"&gt;\
&lt;/span&gt;    &lt;span class="nb"&gt;rm&lt;/span&gt; &lt;span class="nt"&gt;-rf&lt;/span&gt; /var/lib/apt/lists/&lt;span class="k"&gt;*&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The subtlety: deleting files in a later layer doesn't remove them from earlier layers — it just hides them. The data is still baked into the image history. Chaining with &lt;code&gt;&amp;amp;&amp;amp;&lt;/code&gt; ensures everything happens inside a single container state before Docker freezes it into a layer.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does a Python app need C build tools?&lt;/strong&gt; Adding &lt;code&gt;build-essential&lt;/code&gt; and &lt;code&gt;libpq-dev&lt;/code&gt; to a Python Dockerfile initially confused me. I was writing Python — why did I need a C compiler?&lt;/p&gt;

&lt;p&gt;The answer is in how &lt;code&gt;psycopg2&lt;/code&gt; works. Python is a high-level, dynamically typed language — fast to write, but comparatively slow to execute. PostgreSQL is written in C and expects high-speed communication. &lt;code&gt;psycopg2&lt;/code&gt; bridges that gap by wrapping a C extension that handles network sockets, memory management, and binary data streams directly. When Python calls &lt;code&gt;psycopg2.connect()&lt;/code&gt;, it hands the heavy lifting to that underlying C layer.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;build-essential&lt;/code&gt; provides &lt;code&gt;gcc&lt;/code&gt;, the C compiler needed to build that extension.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;libpq-dev&lt;/code&gt; provides the PostgreSQL header files — the "dictionary" the C compiler needs to understand how to communicate with Postgres.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Python Wheels.&lt;/strong&gt; Historically, &lt;code&gt;pip install psycopg2&lt;/code&gt; downloaded raw C source and compiled it locally — slow builds and cryptic errors if the build tools weren't present. Python's solution is &lt;strong&gt;Wheels&lt;/strong&gt; (&lt;code&gt;.whl&lt;/code&gt; files): pre-compiled binary packages built by library maintainers for common operating systems and uploaded to PyPI. When a matching Wheel exists, &lt;code&gt;pip install&lt;/code&gt; just downloads and unpacks it. No C compilation required.&lt;/p&gt;

&lt;h3&gt;
  
  
  Biggest Takeaway
&lt;/h3&gt;

&lt;p&gt;Docker is not magic. It is mostly Linux. The Dockerfile is a sequence of Linux commands executed inside a container environment:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight docker"&gt;&lt;code&gt;&lt;span class="k"&gt;RUN &lt;/span&gt;apt-get update
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;is literally running &lt;code&gt;apt-get update&lt;/code&gt; inside the image. Once I stopped thinking of Docker as a separate abstraction and started reading it as a shell script with layers, the mental overhead dropped significantly.&lt;/p&gt;




&lt;h2&gt;
  
  
  Week 8 — Deployment: Shipping to Staging
&lt;/h2&gt;

&lt;p&gt;Week 8 was the capstone. The goal: a CD pipeline that deploys to staging automatically on every successful merge to main, with the Swagger UI and &lt;code&gt;/health&lt;/code&gt; endpoint publicly accessible.&lt;/p&gt;

&lt;h3&gt;
  
  
  Connecting CI to CD
&lt;/h3&gt;

&lt;p&gt;The interesting design question here wasn't the deployment itself — it was the &lt;em&gt;sequencing&lt;/em&gt;. You only want to deploy when CI passes. A failing test suite means questionable code, and questionable code should never deploy automatically.&lt;/p&gt;

&lt;p&gt;The simplest pattern is a single workflow file using the &lt;code&gt;needs&lt;/code&gt; keyword to create a strict job dependency — Job B won't start until Job A finishes with a green checkmark.&lt;/p&gt;

&lt;p&gt;I opted for two separate files (&lt;code&gt;ci.yml&lt;/code&gt; and &lt;code&gt;deploy.yml&lt;/code&gt;) because separate files communicate intent more clearly and are easier to manage independently. The tradeoff is that the &lt;code&gt;needs&lt;/code&gt; keyword doesn't work across files — they don't share context. The solution is &lt;code&gt;workflow_run&lt;/code&gt;, which lets the deployment workflow listen for the CI workflow to complete:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;workflow_run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;workflows&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CI"&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;completed&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;deploy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.workflow_run.conclusion == 'success' }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This means the deployment job only wakes up when the CI workflow finishes, and only proceeds if it finished successfully.&lt;/p&gt;

&lt;h3&gt;
  
  
  Challenges
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Database driver mismatch.&lt;/strong&gt; Railway provided a standard PostgreSQL connection URL, but SokoFlow's SQLAlchemy engine is configured for async operation using &lt;code&gt;asyncpg&lt;/code&gt;. A plain &lt;code&gt;postgresql://&lt;/code&gt; URL defaults to the synchronous &lt;code&gt;psycopg2&lt;/code&gt; driver, which fails immediately in an async context. The fix is to normalize the driver on startup:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;drivername&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgres&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql+psycopg2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;drivername&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postgresql+asyncpg&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  The Full Deployment Journey
&lt;/h3&gt;

&lt;p&gt;Here's how a single &lt;code&gt;git push&lt;/code&gt; to &lt;code&gt;main&lt;/code&gt; travels from a local machine to a live staging environment:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpmwe9cqd3dv6ckojsha8.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpmwe9cqd3dv6ckojsha8.png" alt="Github Actions Railway Pipeline" width="800" height="812"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;CI pipeline&lt;/strong&gt; — GitHub Actions spins up an isolated runner, installs dependencies, and runs the full test suite. If anything fails, the pipeline stops here.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CD pipeline&lt;/strong&gt; — On CI success, the deployment workflow triggers and connects to Railway.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Build phase&lt;/strong&gt; — Railway reads the Dockerfile and builds the production image.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pre-deploy phase&lt;/strong&gt; — Railway runs &lt;code&gt;alembic upgrade head&lt;/code&gt; in a temporary container, applying any pending database migrations before the new application code goes live.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Live deploy&lt;/strong&gt; — Railway starts the new FastAPI containers and performs a health check. Once they respond successfully, traffic is switched from the old containers to the new ones with minimal downtime.&lt;/li&gt;
&lt;/ol&gt;

&lt;h3&gt;
  
  
  Biggest Takeaway
&lt;/h3&gt;

&lt;p&gt;The cloud is not magic either. Before Week 8, SokoFlow looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FastAPI → localhost → Postgres, Redis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;After Week 8:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;FastAPI → Environment Variables → Railway PostgreSQL, Railway Redis
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The application code is identical. It only knows connection strings. Environment variables replace &lt;code&gt;.env&lt;/code&gt; in production — same variable names, different values, and the application has no idea where they came from.&lt;/p&gt;

&lt;p&gt;And staging is not "production lite." It is a production-identical environment used to validate deployment mechanics, infrastructure configuration, and integration behavior before anything touches real users.&lt;/p&gt;




&lt;h2&gt;
  
  
  Month 1 → Month 2: What Changed
&lt;/h2&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Month 1&lt;/th&gt;
&lt;th&gt;Month 2&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Core business logic&lt;/td&gt;
&lt;td&gt;Automated deployment workflows&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Local development only&lt;/td&gt;
&lt;td&gt;Containerized and reproducible&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Manual test runs&lt;/td&gt;
&lt;td&gt;CI pipeline with enforced coverage gate&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Single synchronous process&lt;/td&gt;
&lt;td&gt;Background workers via Celery&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Runs on my machine&lt;/td&gt;
&lt;td&gt;Runs in the cloud&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;




&lt;h2&gt;
  
  
  Looking Ahead to Month 3: Conversation Engine and Webhook Simulation
&lt;/h2&gt;

&lt;p&gt;Month 3 is the feature I've been looking forward to most since this project started — and probably the hardest one.&lt;/p&gt;

&lt;p&gt;A WhatsApp message looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;"sold 3 milks"
"Add uji 24 pcs @ 85"
"How much stock for soda?"
"Today's report"
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;But the system needs structured actions like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"intent"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"RECORD_SALE"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"product"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Milk 500ml"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"quantity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The core challenge: how do you turn unpredictable human language into deterministic system commands — without the whole thing collapsing into spaghetti at scale?&lt;/p&gt;

&lt;p&gt;SokoFlow's answer is &lt;strong&gt;Finite State Machines (FSMs) + controlled intent parsing + conversational context&lt;/strong&gt;. The easy path would be to drop an LLM into the middle of this and let it handle everything. I didn't take that path — and Month 3's build log explains exactly why.&lt;/p&gt;

&lt;p&gt;Stay tuned.&lt;/p&gt;

</description>
      <category>cloudcomputing</category>
      <category>learning</category>
      <category>docker</category>
      <category>backenddevelopment</category>
    </item>
    <item>
      <title>Shifting Left: How TDD Became the Foundation of SokoFlow's Core Engine</title>
      <dc:creator>Kirera paul murithi</dc:creator>
      <pubDate>Tue, 30 Jun 2026 12:35:46 +0000</pubDate>
      <link>https://dev.to/paulmurithi/shifting-left-how-tdd-became-the-foundation-of-sokoflows-core-engine-485f</link>
      <guid>https://dev.to/paulmurithi/shifting-left-how-tdd-became-the-foundation-of-sokoflows-core-engine-485f</guid>
      <description>&lt;h3&gt;
  
  
  SokoFlow Build Log — Month 1 of 4
&lt;/h3&gt;

&lt;p&gt;Last semester I set out on a new strategic plan to level up my software development skills through deliberate, project-based learning. That work produced one of the most ambitious things I've built so far: &lt;strong&gt;Sim-Pesa&lt;/strong&gt;, a local-first transactional appliance that lets developers working in the M-Pesa ecosystem test and simulate STK Push workflows entirely on their own machines, without depending on the Daraja sandbox. I documented that build in 16 weekly posts, which you can find &lt;a href="https://hashnode.com/@paul-murithi" rel="noopener noreferrer"&gt;here&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This semester, the focus shifts — from fintech foundations to cloud-native integration and real-world systems. The flagship project is &lt;strong&gt;SokoFlow&lt;/strong&gt;, a conversational ERP for small Kenyan shopkeepers to track inventory and record sales entirely through WhatsApp chat. No app to download, no training session required — just natural language.&lt;/p&gt;

&lt;p&gt;Where Sim-Pesa lived in a controlled, predictable transactional world, SokoFlow steps into the mess of cloud-native reality: third-party API failures, webhook signature verification, the statelessness of HTTP, and container orchestration. The target audience shifts too — Kenyan SMEs operating on infrastructure that is often unreliable by design, not by exception.&lt;/p&gt;

&lt;p&gt;It's an ambitious project, but the goal was always to learn as much as possible from it. With the plan in place, I got to work.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The Vision of a Headless ERP
&lt;/h2&gt;

&lt;p&gt;The first real question I had to answer before writing a line of code: what does "headless" actually mean?&lt;/p&gt;

&lt;p&gt;Headless architecture decouples the frontend — the "head," or user interface — from the backend, the "body" that holds the data and business logic. A conventional ERP bundles both: backend plus a dashboard or UI on top. A headless ERP, by contrast, is just the engine. The brain. There's no built-in screen.&lt;/p&gt;

&lt;p&gt;So how do users interact with a system that has no interface of its own? SokoFlow doesn't actually care. It could be:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;WhatsApp&lt;/li&gt;
&lt;li&gt;SMS&lt;/li&gt;
&lt;li&gt;A web app&lt;/li&gt;
&lt;li&gt;A mobile app&lt;/li&gt;
&lt;li&gt;A voice assistant&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;In this case, the "frontend" happens to be a WhatsApp conversation. Instead of clicking "Add Product," the shopkeeper just texts:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Added 5 packets of milk"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;And the backend processes that message the way an ERP would process a structured command.&lt;/p&gt;

&lt;p&gt;In a sense, most modern systems are already headless-ish. Any architecture where the backend exposes an API and the frontend simply consumes JSON over HTTP is practically headless by default — if you've built something like that before, you were already doing this without naming it.&lt;/p&gt;

&lt;p&gt;SokoFlow just makes the principle explicit. The backend is built on the assumption that it may &lt;em&gt;never&lt;/em&gt; have a traditional UI:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Normal web app&lt;/th&gt;
&lt;th&gt;SokoFlow&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Backend exists mainly to serve a website or app&lt;/td&gt;
&lt;td&gt;Backend &lt;strong&gt;is&lt;/strong&gt; the product&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Frontend is the product&lt;/td&gt;
&lt;td&gt;WhatsApp is just one client talking to it&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That framing matters because it means tomorrow I could plug in Telegram, SMS, a voice bot, a React dashboard, or USSD — without touching the core business logic.&lt;/p&gt;

&lt;p&gt;Month 1's task wasn't the exciting part on the surface: four weeks spent entirely on core business logic, with no async layers and no WhatsApp integration in sight. That was deliberate. The core is everything — if it's wrong, the conversation engine built on top of it will be wrong too. That focus is also what pulled me into one of the most talked-about (and most misunderstood) practices in software engineering: &lt;strong&gt;Test-Driven Development&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The Power of TDD in Core Logic
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://en.wikipedia.org/wiki/Test-driven_development" rel="noopener noreferrer"&gt;TDD&lt;/a&gt; is a development style where you write a failing automated test first, write just enough code to make it pass, then refactor both the test and the implementation — and repeat for the next piece of behavior. This was my first real experience working this way, and it initially felt backwards. Most of us default to Design → Write Code → Write Tests. TDD inverts that order entirely.&lt;/p&gt;

&lt;p&gt;Once it clicked, though, it was genuinely simple — the cycle is known as &lt;strong&gt;Red-Green-Refactor&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Red — write a failing test.&lt;/strong&gt; Define exactly what a piece of code should do before it exists. Since only the test exists, it fails by definition.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Green — write just enough code.&lt;/strong&gt; The minimum implementation required to make that test pass. Nothing more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Refactor — clean it up.&lt;/strong&gt; Revisit both the test and the implementation, tighten them up, and confirm the tests still pass.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the entire playbook, repeated feature by feature. After working in this loop for a few weeks, I came around to it completely — it forces you to think through edge cases and the shape of a request-response cycle &lt;em&gt;before&lt;/em&gt; you write the implementation, not after.&lt;/p&gt;

&lt;p&gt;With that approach set, here's how the four weeks broke down:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Week 1 — Project scaffold:&lt;/strong&gt; PostgreSQL schema, Alembic migrations, FastAPI skeleton.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week 2 — Product core:&lt;/strong&gt; 20+ unit tests covering product CRUD operations (add, update, delete, and friends).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week 3 — Inventory management:&lt;/strong&gt; Inventory deduction logic tested against boundary cases.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Week 4 — Sales recording &amp;amp; daily aggregation:&lt;/strong&gt; A sales service backed by report queries that return accurate totals against test data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;By the end of the month: &lt;strong&gt;40+ tests, 92% coverage.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  3. The Technical Hurdles
&lt;/h2&gt;

&lt;p&gt;Everything moved smoothly on the surface, but the real lessons — as always — came from what went wrong along the way.&lt;/p&gt;

&lt;h3&gt;
  
  
  A Wall of New Tooling
&lt;/h3&gt;

&lt;p&gt;This was my first extended stretch writing Python, which meant getting fluent in what I've started calling the "modern Python stack."&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Type hints.&lt;/strong&gt; In old-style Python, &lt;code&gt;def greet(name):&lt;/code&gt; tells you nothing about what &lt;code&gt;name&lt;/code&gt; actually is — a string, a list, a database object — until the code crashes at runtime. Adding type hints like &lt;code&gt;name: str&lt;/code&gt; makes the expectation explicit, both for other developers and for tooling.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;MyPy — the quality inspector.&lt;/strong&gt; Python itself ignores type hints at runtime; they're purely documentation unless something enforces them. MyPy is a static analysis tool that reads code without executing it, catching type errors that would otherwise slip through and surface only in production.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Pydantic — the bouncer.&lt;/strong&gt; FastAPI is built on Pydantic, a data validation library. If type hints are documentation, Pydantic is enforcement: it uses those same type hints to validate that data entering the application — from users, APIs, or the database — is exactly what it claims to be.&lt;/p&gt;

&lt;h3&gt;
  
  
  Fighting Async Testing and DB Drivers
&lt;/h3&gt;

&lt;p&gt;Going all-in on TDD meant spending a lot of time inside the test suite, which meant the environment had to be low-friction enough to iterate quickly. The center of that effort was a single file: &lt;code&gt;conftest.py&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Understanding &lt;code&gt;conftest.py&lt;/code&gt; and fixtures.&lt;/strong&gt; Before pytest's fixture system existed, test setup was a maintenance headache — every test file needed the same boilerplate (database connections, authenticated users, HTTP clients), duplicated across the suite. Any change meant updating dozens of files, and forgotten cleanup could quietly break unrelated tests. &lt;code&gt;conftest.py&lt;/code&gt; exists to centralize that shared setup in one place. Pytest discovers it automatically and makes everything inside available to every test in the directory tree, with no explicit imports required.&lt;/p&gt;

&lt;p&gt;A &lt;strong&gt;fixture&lt;/strong&gt; is just a function that builds whatever a test needs, hands it over with &lt;code&gt;yield&lt;/code&gt;, and guarantees cleanup afterward — even if the test fails. That lets each test focus purely on its assertions. Pytest also lets fixtures live at different scopes, from a fresh instance per test (&lt;code&gt;function&lt;/code&gt;) to a single shared instance for the whole run (&lt;code&gt;session&lt;/code&gt;); the goal is to use the broadest scope that still keeps tests properly isolated.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Moving from sync to async changed more than a few keywords.&lt;/strong&gt; Swapping SQLite and standard SQLAlchemy sessions for &lt;code&gt;asyncpg&lt;/code&gt;, &lt;code&gt;AsyncSession&lt;/code&gt;, and async route handlers introduces an event loop — and every async resource is tied to the loop that created it. By default, &lt;code&gt;pytest-asyncio&lt;/code&gt; spins up a fresh event loop per test, which can leave long-lived objects like the database engine bound to a loop that no longer exists. The fix was a session-scoped &lt;code&gt;event_loop&lt;/code&gt; fixture so the entire suite shares one consistent loop.&lt;/p&gt;

&lt;p&gt;That wasn't the only wrinkle. SQLAlchemy's default connection pooling — great in production — can leak state between tests and cause loop-ownership conflicts, so switching to &lt;code&gt;NullPool&lt;/code&gt; ensures every connection is opened, used, and immediately discarded. FastAPI's synchronous &lt;code&gt;TestClient&lt;/code&gt; also has to bridge sync and async code, which made loop issues more likely; switching to &lt;code&gt;httpx.AsyncClient&lt;/code&gt; kept the tests, the client, and the application running on the same event loop, resulting in a far more reliable setup.&lt;/p&gt;

&lt;h3&gt;
  
  
  Time and Timezones
&lt;/h3&gt;

&lt;p&gt;This trips up even experienced developers, and it caught up with me too — starting with not fully internalizing the difference between &lt;strong&gt;naive&lt;/strong&gt; and &lt;strong&gt;aware&lt;/strong&gt; datetimes.&lt;/p&gt;

&lt;p&gt;A naive datetime — &lt;code&gt;datetime(2026, 6, 25, 15, 0)&lt;/code&gt; — just says "3 PM." But 3 PM &lt;em&gt;where&lt;/em&gt;? London? Nairobi? There's no way to know. An aware datetime — &lt;code&gt;datetime(2026, 6, 25, 15, tzinfo=UTC)&lt;/code&gt; — says "3 PM UTC," which is complete information.&lt;/p&gt;

&lt;p&gt;Picture two servers, one in Kenya and one in New York, both calling &lt;code&gt;datetime.now()&lt;/code&gt;. The Kenya server returns 15:00; the New York server returns 08:00. Same moment, different values — which is exactly the kind of inconsistency that makes naive datetimes unsafe in production. The fix is &lt;code&gt;datetime.now(UTC)&lt;/code&gt;, so every server agrees on a single source of truth.&lt;/p&gt;

&lt;p&gt;The sharpest version of this problem showed up in the daily report logic. To answer "what did this shop sell today," the database needs the start and end of &lt;em&gt;that specific day&lt;/em&gt; — but in &lt;em&gt;which&lt;/em&gt; timezone?&lt;/p&gt;

&lt;p&gt;The shop operates in Kenya, on EAT (UTC+3) year-round. If an owner asks for the report for June 20, they mean everything between midnight and 11:59:59 PM on June 20, &lt;strong&gt;Kenya time&lt;/strong&gt;. In UTC, those boundaries are:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Local (EAT)&lt;/th&gt;
&lt;th&gt;UTC&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Jun 20 00:00&lt;/td&gt;
&lt;td&gt;Jun 19 21:00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jun 21 00:00&lt;/td&gt;
&lt;td&gt;Jun 20 21:00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;So the correct query is &lt;code&gt;created_at &amp;gt;= 2025-06-19T21:00:00Z AND created_at &amp;lt; 2025-06-20T21:00:00Z&lt;/code&gt; — notice that a single "June 20" local day actually spans two different UTC calendar dates.&lt;/p&gt;

&lt;p&gt;My initial implementation got this wrong by treating the date naively:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;UTC&lt;/th&gt;
&lt;th&gt;Kenya (UTC+3)&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Jun 20 00:00&lt;/td&gt;
&lt;td&gt;Jun 20 03:00&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Jun 21 00:00&lt;/td&gt;
&lt;td&gt;Jun 21 03:00&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That version was effectively collecting sales from 3 AM June 20 to 3 AM June 21 Kenya time, instead of midnight to midnight. The result: sales between midnight and 3 AM disappeared from the correct day's report, and late-night sales from the &lt;em&gt;next&lt;/em&gt; day bled into it.&lt;/p&gt;

&lt;p&gt;If a shop closes at 8 PM, this bug is invisible — almost nothing happens between midnight and 3 AM, so the totals come out right by coincidence. But the moment that assumption breaks — late opening hours, an online order at 1 AM, an overnight automated payment, an inventory sync that runs after midnight — the cracks show immediately: June 20's report comes up short, June 21's report has mysterious extra sales, and the daily totals stop matching the receipts.&lt;/p&gt;

&lt;p&gt;The rule of thumb that came out of this: when a user requests a report for a calendar day, interpret that date in the shop's local timezone, compute the local start and end of that day, convert those instants to UTC, and only then query the database (which stores everything in UTC). The underlying principle is that &lt;strong&gt;dates are a local concept; timestamps are absolute instants&lt;/strong&gt; — a "day" has to be defined in local time first, then translated to UTC for storage and querying.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. Looking Ahead to Month 2: Docker, CI/CD, and Infrastructure Automation
&lt;/h2&gt;

&lt;p&gt;With Month 1 behind me and the core business logic locked down under a solid test suite, Month 2 shifts focus from a local code project to production-ready, cloud-native infrastructure. Three things are top of mind for the next four weeks:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Production-grade containerization.&lt;/strong&gt; Orchestrating SokoFlow's full topology with Docker — a multi-stage &lt;code&gt;docker-compose.yml&lt;/code&gt; that cleanly networks the FastAPI gateway, PostgreSQL 15, Redis 7, and a split Celery worker pool (&lt;code&gt;conversation_tasks&lt;/code&gt; and &lt;code&gt;report_tasks&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Automated CI/CD quality gates.&lt;/strong&gt; A strict GitHub Actions pipeline that runs the full test suite on every push and blocks pull requests if coverage drops below 85% or MyPy flags any strict type violations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Environment-aware configuration.&lt;/strong&gt; Hardening configuration management so that switching between development, staging, and production happens cleanly through environment variables, with no changes to application logic required.&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Conclusion &amp;amp; Key Takeaway
&lt;/h2&gt;

&lt;p&gt;Finishing Month 1 confirmed one thing for me: &lt;strong&gt;build the business core before letting any external infrastructure distract you.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Forcing myself into strict TDD from day one surfaced complex boundary cases early — inventory hitting exactly zero, low-stock threshold triggers — without the noise of webhooks, servers, or third-party APIs in the way. Fighting through async test fixtures and timezone mismatches wasn't fun in the moment, but resolving those foundational issues now means Month 2 starts on an airtight, predictable core that's actually ready to scale.&lt;/p&gt;

&lt;p&gt;SokoFlow has its engine. Now it's time to build the container that runs it. Stay locked in for the next build log.&lt;/p&gt;

</description>
      <category>fastapi</category>
      <category>backenddevelopment</category>
      <category>buildinpublic</category>
      <category>postgressql</category>
    </item>
  </channel>
</rss>
