<?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>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>
