<?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: Dan Flanagan</title>
    <description>The latest articles on DEV Community by Dan Flanagan (@logical_bytes).</description>
    <link>https://dev.to/logical_bytes</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%2F4029553%2Fd91d5194-6df4-4ad6-a870-0742a383d66b.png</url>
      <title>DEV Community: Dan Flanagan</title>
      <link>https://dev.to/logical_bytes</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/logical_bytes"/>
    <language>en</language>
    <item>
      <title>The Difference Between Good Code and Bad Code is 5 Minutes</title>
      <dc:creator>Dan Flanagan</dc:creator>
      <pubDate>Thu, 13 Aug 2026 20:22:58 +0000</pubDate>
      <link>https://dev.to/logical_bytes/the-difference-between-good-code-and-bad-code-is-5-minutes-4a99</link>
      <guid>https://dev.to/logical_bytes/the-difference-between-good-code-and-bad-code-is-5-minutes-4a99</guid>
      <description>&lt;p&gt;I'm a fan of the KISS principle. Keep it simple. That applies when you're building something new, but it applies just as much when you're fixing something old.&lt;/p&gt;

&lt;p&gt;Across every role I've had — agency work, staff engineer at a fintech startup, time at AWS and Meta, consulting for startups, building my own products — the pattern is always the same. The difference between code that's maintainable and code that becomes a nightmare is usually about 5 extra minutes of thought. Not hours. Not days. Five minutes to ask: will the next person understand this? Can they extend it? Will they have to undo what I did before they can make progress?&lt;/p&gt;

&lt;p&gt;But here's the nuance: sometimes those 5 minutes are actually 5 months in disguise. Knowing the difference is the real skill.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Skipping the 5 Minutes Costs You
&lt;/h2&gt;

&lt;p&gt;One of my junior engineers manually provisioned an EC2 instance when we needed to spin up a server. We were on a tight deadline, and I get it — clicking through the console is fast. The problem was that we ran everything through CDK. Every piece of infrastructure was defined as code.&lt;/p&gt;

&lt;p&gt;I came down on him pretty hard for it. Not because the EC2 didn't work — it worked fine. But because of the hidden cost he was creating.&lt;/p&gt;

&lt;p&gt;When everything is defined as code, you know what exists, where it's deployed, and how to reproduce it. When someone provisions something manually, you get ghosts in the system. Six months later, someone gets paged at 2am for a service being down, and nobody knows where it was deployed or how it was configured. That's a real problem. And by the way, CDK is terrible at importing existing infrastructure — so migrating that manually created EC2 back into our managed stack was significantly harder than just doing it right the first time.&lt;/p&gt;

&lt;p&gt;The 5 minutes here was writing the CDK construct instead of clicking through the console. He skipped it, and it cost us hours later.&lt;/p&gt;

&lt;p&gt;That said — I want to be honest about context. "Everything as code" was non-negotiable at that scale, with that team size, at AWS. For my own startup products right now? I'm skipping Terraform and creating resources manually. The tradeoff is different when you're a solo founder versus a team of engineers who need to understand each other's infrastructure. The principle isn't "always do it the hard way." It's "understand the cost of the shortcut before you take it."&lt;/p&gt;

&lt;h2&gt;
  
  
  When Spending the 5 Minutes Pays Off
&lt;/h2&gt;

&lt;p&gt;On a recent consulting engagement, I inherited a codebase where the backend had queries timing out at 30+ seconds. The team had worked around it by hardcoding data into static JSON files — a reasonable decision when you need to demo next week.&lt;/p&gt;

&lt;p&gt;When I wired the frontend to the actual API, the pages were unusable. Even loading 100 entries was painfully slow. The problem was one giant JOIN across millions of rows trying to do everything at once.&lt;/p&gt;

&lt;p&gt;I'd already created ORM models for all their tables during my &lt;a href="https://logicalbytes.dev/posts/discovery-process" rel="noopener noreferrer"&gt;discovery phase&lt;/a&gt;. Instead of trying to optimize the existing raw SQL in place, I stripped out the old queries entirely and replaced them with ORM-based ones — decomposed into smaller, faster queries with pagination.&lt;/p&gt;

&lt;p&gt;Query time dropped from 30+ seconds to ~200ms.&lt;/p&gt;

&lt;p&gt;Here's the interesting part: I had Claude write the optimized version in raw SQL as well, just to compare. The ORM-based query was 15% faster. The ORM generates cleaner query plans because it's been optimized for the patterns you're using. Raw SQL scattered through a codebase tends to accumulate inefficiencies that nobody notices.&lt;/p&gt;

&lt;p&gt;The 5 minutes here was replacing the queries with ORM-based ones instead of just optimizing the raw SQL in place. Both would have gotten me to ~200ms. But now every query in the codebase is type-safe, composable, and maintainable. The next developer doesn't have to parse raw SQL strings to understand what's happening.&lt;/p&gt;

&lt;p&gt;I'm a big believer in ORMs — my favorite is &lt;a href="https://entgo.io/" rel="noopener noreferrer"&gt;EntGo&lt;/a&gt;, a graph-based ORM for Go that I loved at Meta — but the principle is the same regardless of language: represent your schemas as code. Mirror the database, don't scatter raw queries through your application.&lt;/p&gt;

&lt;h2&gt;
  
  
  When the 5 Minutes Is Actually 5 Months
&lt;/h2&gt;

&lt;p&gt;Not every "do it right" instinct is correct. Sometimes the extra effort is over-engineering in disguise.&lt;/p&gt;

&lt;p&gt;At a previous role, our engineering team decided to build a GraphQL server. It was the "right" choice architecturally — flexible queries, typed schema, great developer experience. We were excited about it. I was excited about it.&lt;/p&gt;

&lt;p&gt;It was the wrong call.&lt;/p&gt;

&lt;p&gt;Building a GraphQL server &lt;em&gt;right&lt;/em&gt; took way too long. The complexity snowballed. We delayed showing progress to stakeholders because nothing was "ready" yet — the exact mistake I talk about in &lt;a href="https://logicalbytes.dev/posts/demo-to-product-iterate" rel="noopener noreferrer"&gt;telling the story of your work&lt;/a&gt;. We should have built a REST API. It would have been simpler, faster to ship, and honestly better for our use case.&lt;/p&gt;

&lt;p&gt;And then there's the security angle. GraphQL has a fundamental problem with infinitely nested queries. If you expose it to the world as a public API, you're opening yourself up to query depth attacks that are genuinely hard to defend against. We were using it internally, so it was manageable, but I can never recommend GraphQL for a public-facing API again. The edge cases are a nightmare.&lt;/p&gt;

&lt;p&gt;We were spending the extra 5 minutes. But those 5 minutes were actually 5 months. The KISS principle would have told us to build the REST API, ship it, and evaluate whether we actually needed GraphQL later. We didn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  When 15 Hours Is Still the 5-Minute Choice
&lt;/h2&gt;

&lt;p&gt;On that same engagement, the frontend was a Vue app iframing a separate Angular app through an Express server. I launched it locally and an entire section of the UI was blank — errored out. That's when I discovered the iframe setup.&lt;/p&gt;

&lt;p&gt;I could have wired the Vue app to the API around the iframe. It would have "worked." Instead, I spent about 15 hours porting the Angular components into Vue using Claude Code and consolidating into one application. Two cross-dependent repos became one. Two build pipelines became one. The iframe that broke when it failed to load was gone.&lt;/p&gt;

&lt;p&gt;Was 15 hours the "5 minute" choice? Yes — because the alternative was maintaining that complexity indefinitely.&lt;/p&gt;

&lt;p&gt;The same principle applied to customer data isolation. Users could upload documents, but their data wasn't tagged with their user ID. The RAG chatbot searched all data, not just theirs. I could have added a filter at the query layer — check user_id at read time, don't bother tagging at write time. It would have solved the immediate problem.&lt;/p&gt;

&lt;p&gt;Instead, I added user_id at the source. A new &lt;code&gt;documents&lt;/code&gt; table to track uploads, two new columns on the data table, and user_id threaded through the entire pipeline — from upload to extraction to storage to query. The extraction pipeline itself didn't change. The database loader got two new columns. The query layer got one additional WHERE clause.&lt;/p&gt;

&lt;p&gt;Five extra minutes of schema design. But now future features — usage analytics, per-user billing, data export — all have the foundation they need. Without it, every one of those features would start with a data migration.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Actual Principle
&lt;/h2&gt;

&lt;p&gt;The 5-minute rule isn't "always do it the right way." It's a decision framework:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;What's the shortcut?&lt;/strong&gt; Understand it clearly. Sometimes it's the right call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What's the cost of the shortcut 6 months from now?&lt;/strong&gt; If it's "someone gets paged and doesn't know what this is" — spend the 5 minutes. If it's "we'll need to refactor this eventually" — maybe that's fine.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Is the "right way" actually 5 minutes, or is it 5 months?&lt;/strong&gt; If you're building a GraphQL server when a REST API would do, you're not being thorough — you're over-engineering.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The difference between good code and bad code isn't talent or experience or how many design patterns you know. It's the willingness to pause for 5 minutes and think about the person who comes after you. Sometimes that means writing the CDK construct. Sometimes that means shipping the REST API and moving on.&lt;/p&gt;

&lt;p&gt;Know which one you're in. That's the skill.&lt;/p&gt;

</description>
      <category>programming</category>
      <category>refactorit</category>
      <category>architecture</category>
      <category>learning</category>
    </item>
    <item>
      <title>Building Scalable APIs with Go: A Startup's Guide to Not Overengineering</title>
      <dc:creator>Dan Flanagan</dc:creator>
      <pubDate>Thu, 30 Jul 2026 19:44:41 +0000</pubDate>
      <link>https://dev.to/logical_bytes/building-scalable-apis-with-go-a-startups-guide-to-not-overengineering-445d</link>
      <guid>https://dev.to/logical_bytes/building-scalable-apis-with-go-a-startups-guide-to-not-overengineering-445d</guid>
      <description>&lt;p&gt;Every startup engineering conversation eventually arrives at: "But what if we need to scale?"&lt;/p&gt;

&lt;p&gt;Here's the uncomfortable truth: you probably won't. Not in the way Twitter or Uber scaled. The infrastructure decisions that make sense for 100 million users are actively harmful at 1,000.&lt;/p&gt;

&lt;p&gt;I inherited a project recently where the team was running a Kubernetes cluster and paying thousands a month in infrastructure costs. I moved it to Lambda + SQS and AWS App Runner and achieved the same result for about $50/month — without any of the operational headaches of Kubernetes. That's not a rounding error. That's the difference between burning runway and having runway.&lt;/p&gt;

&lt;p&gt;This post is about right-sizing your API architecture. We'll do the math on when you actually need to worry about scale, why containerization beats serverless for most APIs, and how to build systems that are simple enough to debug at 3 AM but robust enough to grow with you.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Scale Math Most Startups Ignore
&lt;/h2&gt;

&lt;p&gt;Let's start with numbers. Engineers love talking about "millions of requests," but let's break down what that actually means.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;AWS Lambda limits&lt;/strong&gt; (as of 2024):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1,000 concurrent executions (default, can request increase)&lt;/li&gt;
&lt;li&gt;10,000 concurrent executions (typical raised limit)&lt;/li&gt;
&lt;li&gt;15-minute maximum execution time&lt;/li&gt;
&lt;li&gt;~$0.20 per million requests + compute time&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;GCP Cloud Run limits&lt;/strong&gt; :&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;1,000 concurrent requests per instance (configurable)&lt;/li&gt;
&lt;li&gt;Up to 1,000 instances per service (default)&lt;/li&gt;
&lt;li&gt;No execution time limit for HTTP&lt;/li&gt;
&lt;li&gt;~$0.40 per vCPU-hour + requests&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now let's work backwards from "I need more than Lambda can handle":&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Timeframe&lt;/th&gt;
&lt;th&gt;Requests&lt;/th&gt;
&lt;th&gt;Requests/sec&lt;/th&gt;
&lt;th&gt;Reality Check&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;1 month&lt;/td&gt;
&lt;td&gt;10M&lt;/td&gt;
&lt;td&gt;~4/sec&lt;/td&gt;
&lt;td&gt;Seed stage SaaS&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 month&lt;/td&gt;
&lt;td&gt;100M&lt;/td&gt;
&lt;td&gt;~40/sec&lt;/td&gt;
&lt;td&gt;Growing startup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 month&lt;/td&gt;
&lt;td&gt;1B&lt;/td&gt;
&lt;td&gt;~400/sec&lt;/td&gt;
&lt;td&gt;Series B+&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;1 month&lt;/td&gt;
&lt;td&gt;10B&lt;/td&gt;
&lt;td&gt;~4,000/sec&lt;/td&gt;
&lt;td&gt;You have a scaling team&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;With 10,000 concurrent Lambda executions&lt;/strong&gt; and assuming 100ms average response time, you can handle:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;10,000 concurrent × (1000ms / 100ms) = 100,000 requests/second

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's &lt;strong&gt;8.6 billion requests per month&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;Unless you're building the next TikTok, Lambda can handle your traffic. The question isn't whether serverless &lt;em&gt;can&lt;/em&gt; scale—it's whether it &lt;em&gt;should&lt;/em&gt; be your architecture.&lt;/p&gt;

&lt;p&gt;I've had this conversation with founding engineers where we agreed: if we have to move off Lambda, that's an earned problem. Until then, we're fine. That framing — "earned problem" — is the right way to think about scale decisions. Don't solve problems you don't have yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  Decision Framework: Serverless vs Containers
&lt;/h2&gt;

&lt;p&gt;The choice isn't about scale—both can handle massive traffic. It's about development workflow and cost structure.&lt;/p&gt;

&lt;h3&gt;
  
  
  Choose Lambda (or Cloud Functions, Azure Functions) when:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;✅ &lt;strong&gt;Event-driven workloads&lt;/strong&gt; (S3 triggers, queue consumers, webhooks)&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Infrequent, bursty traffic&lt;/strong&gt; (cron jobs, admin tools)&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Very low traffic&lt;/strong&gt; (&amp;lt;1M requests/month) where pay-per-invocation wins&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Simple, single-purpose functions&lt;/strong&gt; (image resizing, data transformation)&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;You're already deep in AWS/GCP/Azure&lt;/strong&gt; and want native integrations&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Choose Containers (Cloud Run, ECS Fargate, ACA) when:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;✅ &lt;strong&gt;User-facing APIs&lt;/strong&gt; where latency consistency matters&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Long-running operations&lt;/strong&gt; (&amp;gt;15 minutes, websockets, streaming)&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Local development is critical&lt;/strong&gt; (same container runs everywhere)&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;VPC/database access&lt;/strong&gt; (containers handle this more cleanly)&lt;/li&gt;
&lt;li&gt;✅ &lt;strong&gt;Predictable traffic&lt;/strong&gt; (&amp;gt;5M requests/month) where reserved capacity is cheaper&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  The Hybrid Approach (Recommended)
&lt;/h3&gt;

&lt;p&gt;Use both. Containers for your API. Serverless for background jobs.&lt;/p&gt;

&lt;p&gt;Example:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Containers:&lt;/strong&gt; User-facing API endpoints&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serverless:&lt;/strong&gt; S3-triggered processing, scheduled cleanup jobs&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Serverless:&lt;/strong&gt; Webhook handlers for Stripe, SendGrid, etc.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each tool for its strength.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Containers Often Win for APIs
&lt;/h2&gt;

&lt;p&gt;For user-facing APIs, managed containers (Cloud Run, ECS Fargate, Azure Container Apps) typically beat serverless functions. Here's why:&lt;/p&gt;

&lt;h3&gt;
  
  
  Consistent Latency
&lt;/h3&gt;

&lt;p&gt;Lambda cold starts range from 100ms (Go, Node.js) to several seconds (Java, C#, Python with heavy dependencies). For a CLI tool or batch job, this is fine. For an API where p99 latency matters, cold starts show up in your metrics and user complaints.&lt;/p&gt;

&lt;p&gt;Managed containers keep instances warm by default. Your p99 latency stays consistent because you're not paying the cold start tax on the first request after idle.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Typical latency profiles:&lt;/strong&gt;&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Lambda (with cold starts)&lt;/th&gt;
&lt;th&gt;Managed Containers&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;p50&lt;/td&gt;
&lt;td&gt;50ms&lt;/td&gt;
&lt;td&gt;45ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;150ms&lt;/td&gt;
&lt;td&gt;120ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p99&lt;/td&gt;
&lt;td&gt;800ms (cold start)&lt;/td&gt;
&lt;td&gt;180ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  Local Development That Matches Production
&lt;/h3&gt;

&lt;p&gt;A Dockerfile that works locally works in production:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Local&lt;/span&gt;
docker run &lt;span class="nt"&gt;-p&lt;/span&gt; 8080:8080 &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="nv"&gt;DATABASE_URL&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;... my-service

&lt;span class="c"&gt;# Production (Cloud Run)&lt;/span&gt;
gcloud run deploy my-service &lt;span class="nt"&gt;--image&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;gcr.io/project/my-service

&lt;span class="c"&gt;# Production (ECS)&lt;/span&gt;
aws ecs update-service &lt;span class="nt"&gt;--service&lt;/span&gt; my-service &lt;span class="nt"&gt;--force-new-deployment&lt;/span&gt;

&lt;span class="c"&gt;# Production (Azure)&lt;/span&gt;
az containerapp update &lt;span class="nt"&gt;--name&lt;/span&gt; my-service &lt;span class="nt"&gt;--image&lt;/span&gt; myregistry.azurecr.io/my-service

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Lambda requires SAM CLI, LocalStack, or other emulators. The development environment never quite matches production. Containers are the same everywhere.&lt;/p&gt;

&lt;h4&gt;
  
  
  Easier Debugging
&lt;/h4&gt;

&lt;p&gt;When something breaks in production:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Pull the exact container image that's running&lt;/li&gt;
&lt;li&gt;Run it locally with production environment variables&lt;/li&gt;
&lt;li&gt;Reproduce the issue&lt;/li&gt;
&lt;li&gt;Fix it&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Lambda's execution environment is opaque. You're debugging through CloudWatch logs and educated guesses about what the runtime environment looks like.&lt;/p&gt;

&lt;h4&gt;
  
  
  No Vendor Lock-In
&lt;/h4&gt;

&lt;p&gt;Containers are portable. Your Cloud Run service can move to ECS, Kubernetes, or a VPS with minimal changes. Lambda functions require rewriting for each provider's API.&lt;/p&gt;

&lt;h3&gt;
  
  
  When Lambda Still Wins
&lt;/h3&gt;

&lt;p&gt;Lambda is better for event-driven workloads (S3 triggers, SQS consumers), infrequent bursty traffic (webhooks, cron jobs), and cost optimization at very low scale (&amp;lt;1M requests/month).&lt;/p&gt;

&lt;p&gt;One nuance worth mentioning: at AWS, we don't consider Lambda statically stable — meaning you have to engineer around the possibility that it can't scale when you need it to. The fan-out technique helps, or you can use ECR + ECS with smart autoscaling. The key is don't set the limits high until you need to, and monitor throughput. You don't want to overallocate resources. For most startups this doesn't matter, but if you're building on Lambda at scale, it's worth understanding.&lt;/p&gt;

&lt;p&gt;Use the right tool for each job.&lt;/p&gt;

&lt;p&gt;Choosing Your Managed Container Platform All three major cloud providers offer managed containers:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Feature&lt;/th&gt;
&lt;th&gt;Cloud Run (GCP)&lt;/th&gt;
&lt;th&gt;ECS Fargate (AWS)&lt;/th&gt;
&lt;th&gt;Azure Container Apps&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Pricing&lt;/td&gt;
&lt;td&gt;$0.40/vCPU-hour&lt;/td&gt;
&lt;td&gt;$0.04/vCPU-hour + $0.004/GB-hour&lt;/td&gt;
&lt;td&gt;$0.40/vCPU-hour&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Free tier&lt;/td&gt;
&lt;td&gt;2M requests/month&lt;/td&gt;
&lt;td&gt;None&lt;/td&gt;
&lt;td&gt;180,000 vCPU-seconds/month&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Max timeout&lt;/td&gt;
&lt;td&gt;60 min (HTTP), unlimited (jobs)&lt;/td&gt;
&lt;td&gt;No limit&lt;/td&gt;
&lt;td&gt;30 min&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Autoscaling&lt;/td&gt;
&lt;td&gt;0 to 1000 instances&lt;/td&gt;
&lt;td&gt;0 to many (configure)&lt;/td&gt;
&lt;td&gt;0 to 30 instances&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Cold starts&lt;/td&gt;
&lt;td&gt;Minimal (keeps warm)&lt;/td&gt;
&lt;td&gt;Minimal&lt;/td&gt;
&lt;td&gt;Minimal&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Complexity&lt;/td&gt;
&lt;td&gt;Lowest&lt;/td&gt;
&lt;td&gt;Medium (task definitions)&lt;/td&gt;
&lt;td&gt;Low-medium&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Decision factors:&lt;/p&gt;

&lt;h3&gt;
  
  
  Choose Cloud Run if:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;You're on GCP or starting fresh&lt;/li&gt;
&lt;li&gt;You want the simplest deployment experience&lt;/li&gt;
&lt;li&gt;You value scale-to-zero with minimal cold starts&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Choose ECS Fargate if:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;You're already on AWS&lt;/li&gt;
&lt;li&gt;You need tight integration with AWS services (RDS, S3, etc.)&lt;/li&gt;
&lt;li&gt;You want more control over networking/security&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Choose Azure Container Apps if:
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;You're on Azure or use .NET heavily&lt;/li&gt;
&lt;li&gt;You want Kubernetes-like features without Kubernetes&lt;/li&gt;
&lt;li&gt;You need DAPR integration&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three are good choices. Pick based on your existing cloud provider, not container platform features.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Simplest Architecture That Works
&lt;/h2&gt;

&lt;p&gt;Here's what a simple, scalable API architecture looks like:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                    ┌─────────────────┐
                    │ Load Balancer   │
                    │ (Cloud LB/ALB)  │
                    └────────┬────────┘
                             │
         ┌───────────────────┼───────────────────┐
         │                   │                   │
         ▼                   ▼                   ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Auth Service    │ │ Billing Service │ │ API Service     │
│ (Container)     │ │ (Container)     │ │ (Container)     │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
         │                   │                   │
         └───────────────────┼───────────────────┘
                             │
                    ┌────────▼────────┐
                    │ Database        │
                    │ Managed Postgres│
                    └─────────────────┘

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's it. No Kubernetes (yet). No service mesh. No API gateway. No message queues for synchronous operations.&lt;/p&gt;

&lt;p&gt;Each service:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Is a single Go binary in a Docker container&lt;/li&gt;
&lt;li&gt;Connects directly to the database&lt;/li&gt;
&lt;li&gt;Handles its own authentication via shared middleware&lt;/li&gt;
&lt;li&gt;Scales independently based on CPU/memory&lt;/li&gt;
&lt;li&gt;Start here. Add complexity when metrics force you to, not before.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  What You Probably Don't Need
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Kubernetes&lt;/strong&gt;. Managed container platforms (Cloud Run, ECS Fargate, Azure Container Apps) give you orchestration, autoscaling, and load balancing without operational overhead. That project I mentioned in the intro — the one running K8s for thousands a month — didn't need any of it. Lambda + SQS + App Runner gave them the same functionality at a fraction of the cost and complexity. When you need K8s features (custom networking, stateful workloads, multi-cloud), you'll know. Until then, skip it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;API Gateway&lt;/strong&gt;. Managed containers provide HTTPS endpoints, SSL termination, and basic routing. Authentication can live in shared middleware. Rate limiting can live in middleware or at the load balancer. Add Kong or AWS API Gateway when you need centralized policy management—not before.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Service Mesh&lt;/strong&gt;. Your services can talk via HTTP. Istio adds observability, traffic management, and security—but also complexity. Start with simple HTTP calls and structured logging. Add a mesh when debugging distributed systems becomes painful (typically 10+ services, 5+ engineers).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Event Bus for Sync Operations&lt;/strong&gt;. If Service A needs data from Service B right now, make an HTTP call. Don't wrap everything in Pub/Sub or Kafka. Use event buses for async operations (notifications, analytics, fan-out), not request/response.&lt;/p&gt;

&lt;h2&gt;
  
  
  Go Patterns That Actually Matter
&lt;/h2&gt;

&lt;p&gt;Forget the "high-throughput" patterns you read about for building the next trading platform. Here's what actually matters for startup APIs:&lt;/p&gt;

&lt;h3&gt;
  
  
  Bounded Concurrency
&lt;/h3&gt;

&lt;p&gt;The one pattern worth implementing properly: don't spawn unbounded goroutines.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Bad: unbounded goroutine creation&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;ProcessRequests&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requests&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="n"&gt;handleRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// This will kill you under load&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// Good: worker pool with bounded concurrency&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;ProcessWithPool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;requests&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;-&lt;/span&gt;&lt;span class="k"&gt;chan&lt;/span&gt; &lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;numWorkers&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;numWorkers&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;go&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;handleRequest&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;req&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For most APIs, you won't need this. HTTP servers handle concurrency for you. But for background processing, batch jobs, or fan-out operations, bounded concurrency prevents OOM kills.&lt;/p&gt;

&lt;h3&gt;
  
  
  Timeouts on Everything
&lt;/h3&gt;

&lt;p&gt;Every external call needs a timeout. Every database query. Every HTTP request. No exceptions.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="m"&gt;5&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Second&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;cancel&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;result&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueryContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&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;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeadlineExceeded&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"query timeout: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The default timeout should be aggressive. 5 seconds for HTTP calls. 10 seconds for database queries. If you need longer, make it explicit and justify it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Circuit Breakers for External Services
&lt;/h3&gt;

&lt;p&gt;When calling third-party APIs (Stripe, SendGrid, external services), implement circuit breakers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;CircuitBreaker&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;failures&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;maxFailures&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;resetAfter&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;
    &lt;span class="n"&gt;lastFailure&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;
    &lt;span class="n"&gt;mu&lt;/span&gt; &lt;span class="n"&gt;sync&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Mutex&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cb&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;CircuitBreaker&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt; &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lock&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;cb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;failures&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;maxFailures&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;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Since&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lastFailure&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resetAfter&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;ErrCircuitOpen&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;failures&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="c"&gt;// Reset after timeout&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

    &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;fn&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;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Lock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;failures&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
        &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lastFailure&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;cb&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mu&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unlock&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When Stripe has an outage, you don't want every request in your system waiting 30 seconds for a timeout. Circuit breakers fail fast after detecting problems.&lt;/p&gt;

&lt;h3&gt;
  
  
  Structured Logging from Day One
&lt;/h3&gt;

&lt;p&gt;Not a performance pattern, but critical for debugging:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"request processed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"user_id"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;userID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Milliseconds&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="s"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Use slog (Go 1.21+) or zerolog. JSON output. Structured fields. When you're debugging at 3 AM, &lt;code&gt;grep&lt;/code&gt; and &lt;code&gt;jq&lt;/code&gt; are your friends.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Actually Causes Scale Problems
&lt;/h2&gt;

&lt;p&gt;In my experience, scale problems at startups are rarely about request volume. They're about bad query patterns and synchronous bottlenecks. On a recent consulting engagement — the same project I talk about in my &lt;a href="https://logicalbytes.dev/posts/discovery-process" rel="noopener noreferrer"&gt;discovery process&lt;/a&gt; and &lt;a href="https://logicalbytes.dev/posts/technical-deep-dive" rel="noopener noreferrer"&gt;5-minute rule&lt;/a&gt; posts — I inherited queries timing out at 30+ seconds. The team had worked around it by hardcoding data into static JSON files. When I wired the frontend to the actual API, pages were unusable.&lt;/p&gt;

&lt;h3&gt;
  
  
  N+1 Queries
&lt;/h3&gt;

&lt;p&gt;Your API fetches a list of 100 users, then makes 100 database queries to get their profiles. This is 101 queries instead of 2.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Bad: N+1&lt;/span&gt;
&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT id FROM users LIMIT 100"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;
    &lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Scan&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;&amp;amp;&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;profile&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT * FROM profiles WHERE user_id = ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// Good: Single query with JOIN or IN clause&lt;/span&gt;
&lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="s"&gt;`
    SELECT u.id, p.*
    FROM users u
    LEFT JOIN profiles p ON p.user_id = u.id
    LIMIT 100
`&lt;/span&gt;
&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;N+1 queries are the #1 cause of slow APIs I've seen. Use query logging in development to catch them.&lt;/p&gt;

&lt;p&gt;There are a few ways to reduce processing time beyond fixing N+1s: you can parallelize queries and split them into smaller chunks for large data processing, or for database queries, a few smaller queries with code-level processing is often easier and faster than one giant complex JOIN. On that consulting project, I replaced the massive queries with smaller, decomposed ORM-based ones — query time dropped from 30+ seconds to ~200ms.&lt;/p&gt;

&lt;h3&gt;
  
  
  Unbounded Result Sets
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Bad: fetching all records&lt;/span&gt;
&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT * FROM users"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// Good: always paginate&lt;/span&gt;
&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Query&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"SELECT * FROM users LIMIT 100 OFFSET ?"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a table can grow unboundedly, every query needs a &lt;code&gt;LIMIT&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Missing Indexes
&lt;/h3&gt;

&lt;p&gt;Your query is slow? Check &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;. Add an index. This solves 90% of database performance issues.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- Check query plan&lt;/span&gt;
&lt;span class="k"&gt;EXPLAIN&lt;/span&gt; &lt;span class="k"&gt;ANALYZE&lt;/span&gt; &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;user_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;123&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- Add index if missing&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;idx_orders_user_id&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Synchronous External Calls
&lt;/h3&gt;

&lt;p&gt;Don't call Stripe, send emails, or hit external APIs in the request path if you can avoid it. Queue the work, return immediately, process async.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Bad: synchronous email in request handler&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;HandleSignup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;createUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;sendWelcomeEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c"&gt;// This blocks the response&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewEncoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// Good: async via queue&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;HandleSignup&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;user&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;createUser&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;queue&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Publish&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"send-welcome-email"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewEncoder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Encode&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;user&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Measuring Success: Metrics That Matter
&lt;/h2&gt;

&lt;p&gt;Track these four metrics. Ignore everything else until they tell you to optimize:&lt;/p&gt;

&lt;h3&gt;
  
  
  1. Request Latency (p50, p95, p99)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Middleware to track latency&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;LatencyMiddleware&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;next&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&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="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandlerFunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;start&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;next&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ServeHTTP&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Since&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;start&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Info&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"request"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&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;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Milliseconds&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What to watch:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;p50 &amp;lt; 100ms: Good&lt;/li&gt;
&lt;li&gt;p95 &amp;lt; 200ms: Good&lt;/li&gt;
&lt;li&gt;p99 &amp;lt; 500ms: Acceptable for most APIs&lt;/li&gt;
&lt;li&gt;p99 &amp;gt; 1000ms: Investigate&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  2. Error Rate
&lt;/h3&gt;

&lt;p&gt;5xx responses as a percentage of total requests.&lt;/p&gt;

&lt;p&gt;Acceptable thresholds:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&amp;lt;0.1%: Excellent&lt;/li&gt;
&lt;li&gt;0.1-1%: Good&lt;/li&gt;
&lt;li&gt;1-5%: Needs attention&lt;/li&gt;
&lt;li&gt;5%: On fire&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  3. Database Query Time
&lt;/h3&gt;

&lt;p&gt;Slow queries kill APIs. Log all queries over 100ms:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;SlowQueryLogger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt;&lt;span class="p"&gt;)&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;duration&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;100&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Millisecond&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;logger&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Warn&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"slow query"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"query"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="s"&gt;"duration_ms"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Milliseconds&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
        &lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4. Saturation (CPU/Memory)
&lt;/h3&gt;

&lt;p&gt;CPU &amp;gt; 80%: You're at capacity, need to scale Memory &amp;gt; 80%: Check for leaks&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;When to add more metrics:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You have 5+ services: Add distributed tracing&lt;/li&gt;
&lt;li&gt;You have 10+ engineers: Add service-level SLOs&lt;/li&gt;
&lt;li&gt;You have paying customers: Add business metrics&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Start simple. Add complexity when the simple metrics stop being enough.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Migration Path: When to Evolve
&lt;/h2&gt;

&lt;p&gt;You don't have to choose your architecture forever. Here's the typical evolution:&lt;/p&gt;

&lt;h3&gt;
  
  
  Phase 1: Start with Serverless (Month 1-6)
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Deploy Lambda functions (or equivalent)&lt;/li&gt;
&lt;li&gt;No container knowledge required&lt;/li&gt;
&lt;li&gt;Pay-per-invocation pricing&lt;/li&gt;
&lt;li&gt;Fast time to market&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Move to Phase 2 when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cold starts affect user experience (p95 &amp;gt; 500ms)&lt;/li&gt;
&lt;li&gt;Local development is slowing your team&lt;/li&gt;
&lt;li&gt;You need VPC/database access&lt;/li&gt;
&lt;li&gt;You're processing &amp;gt;5M requests/month&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Phase 2: Migrate to Managed Containers (Month 6-24+)**
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;Containerize your services&lt;/li&gt;
&lt;li&gt;Deploy to Cloud Run/Fargate/ACA&lt;/li&gt;
&lt;li&gt;Same autoscaling, lower operational burden than K8s&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most startups stay here for years&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Move to Phase 3 when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;You have 5+ engineers dedicated to platform&lt;/li&gt;
&lt;li&gt;You need multi-region active-active&lt;/li&gt;
&lt;li&gt;You need custom networking/service mesh&lt;/li&gt;
&lt;li&gt;You're running 20+ services&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Phase 3: Kubernetes (Year 2+)&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Move to GKE, EKS, or AKS&lt;/li&gt;
&lt;li&gt;Full control, full complexity&lt;/li&gt;
&lt;li&gt;Requires dedicated platform team&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Most startups stay in Phase 2 for years. That's success, not failure.&lt;/strong&gt; Kubernetes is not a goal—it's a tool for specific problems.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Anti-Patterns to Avoid
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Don't Prematurely Distribute
&lt;/h3&gt;

&lt;p&gt;If your services are in the same monorepo, deploying to the same cloud, and owned by the same team—consider whether they need to be separate services at all.&lt;/p&gt;

&lt;p&gt;A single service with good module boundaries is easier to develop, test, and debug than three services that always deploy together.&lt;/p&gt;

&lt;h3&gt;
  
  
  Don't Add Caching Before You Need It
&lt;/h3&gt;

&lt;p&gt;Redis adds operational complexity. Before adding a cache:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Check your database queries (indexes? N+1?)&lt;/li&gt;
&lt;li&gt;Check your database connection pooling&lt;/li&gt;
&lt;li&gt;Profile actual request latency&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Caching hides problems. Fix the root cause first.&lt;/p&gt;

&lt;h3&gt;
  
  
  Don't Use Kubernetes Until You Have a Platform Team
&lt;/h3&gt;

&lt;p&gt;Kubernetes is powerful. Kubernetes is also a full-time job to operate. Cloud Run, ECS, and similar managed services give you 80% of the benefits with 10% of the complexity.&lt;/p&gt;

&lt;p&gt;When you have dedicated platform engineers and multi-region requirements, revisit this decision.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Mistakes: A Checklist
&lt;/h2&gt;

&lt;p&gt;Before you ship, audit your API for these common issues:&lt;/p&gt;

&lt;h2&gt;
  
  
  Security
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[] All external calls have timeouts&lt;/li&gt;
&lt;li&gt;[] Database queries are parameterized (no SQL injection)&lt;/li&gt;
&lt;li&gt;[] Secrets are in environment variables, not code&lt;/li&gt;
&lt;li&gt;[] HTTPS only (no HTTP endpoints)&lt;/li&gt;
&lt;li&gt;[] Authentication on every endpoint except health checks&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Performance
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[] All list endpoints have pagination (LIMIT clause)&lt;/li&gt;
&lt;li&gt;[] Database has indexes for common queries&lt;/li&gt;
&lt;li&gt;[] N+1 queries are eliminated (use JOINs or batching)&lt;/li&gt;
&lt;li&gt;[] External calls happen async when possible&lt;/li&gt;
&lt;li&gt;[] Circuit breakers on third-party services&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Reliability
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[] Health check endpoint (/health) for load balancer&lt;/li&gt;
&lt;li&gt;[] Graceful shutdown (finish in-flight requests)&lt;/li&gt;
&lt;li&gt;[] Structured logging with request IDs&lt;/li&gt;
&lt;li&gt;[] Error responses include correlation IDs&lt;/li&gt;
&lt;li&gt;[] Database connection pooling configured&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Operations
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;[] Can run entire stack locally with docker-compose&lt;/li&gt;
&lt;li&gt;[] README has setup instructions&lt;/li&gt;
&lt;li&gt;[] Environment variables documented&lt;/li&gt;
&lt;li&gt;[] Deployment is automated (no manual steps)&lt;/li&gt;
&lt;li&gt;[] Rollback process is documented&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;If you can't check all these boxes, you're not ready for production.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion: Build for Today, Prepare for Tomorrow
&lt;/h2&gt;

&lt;p&gt;The goal isn't to handle Twitter-scale traffic. It's to:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Ship fast - Simple architectures have fewer moving parts&lt;/li&gt;
&lt;li&gt;Debug easily - When things break at 3 AM, can you fix it?&lt;/li&gt;
&lt;li&gt;Grow incrementally - Can you handle 10x without rewriting?&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;strong&gt;The architecture that works for most startups:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Managed containers (Cloud Run, ECS Fargate, Azure Container Apps)&lt;/li&gt;
&lt;li&gt;Managed database (Cloud SQL, RDS, Azure Database)&lt;/li&gt;
&lt;li&gt;Structured logging + basic metrics (p50/p95/p99, error rate)&lt;/li&gt;
&lt;li&gt;Automated deployment&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;When to evolve:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Phase 1 → 2: Cold starts hurt user experience, local dev is painful&lt;/li&gt;
&lt;li&gt;Phase 2 → 3: 20+ services, 5+ platform engineers, multi-region requirements&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most startups stay in Phase 2 for years. That's success, not failure.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Your pre-launch checklist:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;[] Can you run the full stack locally?&lt;/li&gt;
&lt;li&gt;[] Do you have health checks and graceful shutdown?&lt;/li&gt;
&lt;li&gt;[] Are database queries indexed and paginated?&lt;/li&gt;
&lt;li&gt;[] Do you have timeouts on all external calls?&lt;/li&gt;
&lt;li&gt;[] Can you deploy with a single command?&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If yes, you're ready. Ship it. Monitor it. Scale when the metrics tell you to, not when your ego does.&lt;/p&gt;

&lt;p&gt;Further reading:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://pilum.dev" rel="noopener noreferrer"&gt;Pilum&lt;/a&gt; — my open-source multi-cloud deployment orchestrator. It started as too many Python scripts and grew into a proper CLI in Go, which is a story for another post.&lt;/li&gt;
&lt;li&gt;&lt;a href="https://12factor.net/" rel="noopener noreferrer"&gt;The Twelve-Factor App&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://go.dev/doc/effective_go" rel="noopener noreferrer"&gt;Golang Best Practices&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>startup</category>
      <category>api</category>
      <category>programming</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Pilum: From Launch to Production-Ready in 3 Months</title>
      <dc:creator>Dan Flanagan</dc:creator>
      <pubDate>Tue, 28 Jul 2026 15:44:03 +0000</pubDate>
      <link>https://dev.to/logical_bytes/pilum-from-launch-to-production-ready-in-3-months-3769</link>
      <guid>https://dev.to/logical_bytes/pilum-from-launch-to-production-ready-in-3-months-3769</guid>
      <description>&lt;p&gt;Github URL: &lt;a href="https://github.com/SID-Technologies/Pilum" rel="noopener noreferrer"&gt;https://github.com/SID-Technologies/Pilum&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;In December 2025, I &lt;a href="https://logicalbytes.dev/blog/introducing-pilum" rel="noopener noreferrer"&gt;open-sourced Pilum&lt;/a&gt;, a multi-cloud deployment CLI that deploys to Cloud Run, Lambda, Azure, Cloudflare Pages, npm, Homebrew, and Docker Hub from a single &lt;code&gt;pilum.yaml&lt;/code&gt;. The announcement post covered the architecture: recipes, ingredients, handlers, wave-based execution.&lt;/p&gt;

&lt;p&gt;That was the "it compiles and the tests pass" version.&lt;/p&gt;

&lt;p&gt;Three months and 40+ pull requests later, Pilum deploys all of &lt;a href="https://romans.dev" rel="noopener noreferrer"&gt;SID Technologies&lt;/a&gt;, platform-core, Torch, Statio, every website, every npm package, and itself. This post is about everything that broke between "it works" and "it ships production software."&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(&lt;/em&gt;&lt;em&gt;Update:&lt;/em&gt;* there's now a seven-months-in addendum at the bottom. It's mostly about what stopped breaking.)*&lt;/p&gt;




&lt;h2&gt;
  
  
  The Timeline
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dec 2, 2025&lt;/strong&gt;: First commit. Baseline CLI with recipe system. (#1)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dec 4&lt;/strong&gt;: Homebrew release workflow. Pilum dogfoods its own deployment. (#2-#4)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Dec 31&lt;/strong&gt;: Service graph, &lt;code&gt;--only-changed&lt;/code&gt;, file embedding support. The "I need this for real" features. (#22-#24)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jan 3-12&lt;/strong&gt;: Documentation and bug fixes. The quiet "oh, this doesn't actually work" phase. (#25-#29)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feb 6-7&lt;/strong&gt;: The big feature sprint — 8 features in 48 hours. Wave deployments, npm recipe, Cloudflare Pages, Azure Container Apps, Cloud Run Jobs, environment variables, JSON output, history command. (#30-#38)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feb 8-12&lt;/strong&gt;: The big fix sprint — YAML parsing, package manager issues, build failures, error swallowing, GCP secrets, Cloudflare execution. Everything from the feature sprint broke something. (#42-#49)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feb 24&lt;/strong&gt;: Security hardening and npm publishing fixes. (#51-#53)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mar-Apr&lt;/strong&gt;: Memory-based worker allocation, wave ordering bug, orchestrator rewrite. The "I thought this was done" phase. (#58-#62)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The pattern is clear: &lt;strong&gt;features ship fast, fixes ship faster, and the real bugs show up a month later.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  Wave-Based Deployments Were Broken
&lt;/h2&gt;

&lt;p&gt;Wave-based deployment was the headline feature in #31. Services declare dependencies, Pilum builds a dependency graph, topologically sorts it into waves, and executes waves in parallel.&lt;/p&gt;

&lt;p&gt;It worked perfectly in tests. Then I tried to publish npm packages.&lt;/p&gt;

&lt;p&gt;The problem: Pilum publishes &lt;code&gt;@sid-technologies/base-ui&lt;/code&gt;, which depends on &lt;code&gt;@repo/configs&lt;/code&gt;. Both are in the same monorepo. Wave ordering correctly put &lt;code&gt;configs&lt;/code&gt; before &lt;code&gt;base-ui&lt;/code&gt;. But the worker allocation was distributing work across goroutines without respecting wave boundaries — workers from wave 2 could start before wave 1 fully drained.&lt;/p&gt;

&lt;p&gt;The npm registry would reject &lt;code&gt;base-ui&lt;/code&gt; because &lt;code&gt;configs&lt;/code&gt; hadn't finished publishing yet. Sometimes it worked (race condition timing), sometimes it didn't.&lt;/p&gt;

&lt;p&gt;The fix (#61) was two things: memory-based worker allocation (don't spawn more workers than the machine can handle) and a proper wave barrier, wave N+1 doesn't start until every worker in wave N has completed and reported success. The entire orchestrator got rewritten in #60 to make this possible — &lt;code&gt;runner.go&lt;/code&gt; (772 lines) was replaced with &lt;code&gt;execution.go&lt;/code&gt;, &lt;code&gt;pipeline.go&lt;/code&gt;, &lt;code&gt;steps.go&lt;/code&gt;, and &lt;code&gt;waves.go&lt;/code&gt; (total: ~773 lines, but now testable and correct).&lt;/p&gt;




&lt;h2&gt;
  
  
  Error Swallowing
&lt;/h2&gt;

&lt;p&gt;This was the scariest bug (#49). The worker queue was silently swallowing errors from shell commands. A deployment step would fail, the worker would log it, but the orchestrator wouldn't see the failure. The deploy would "succeed" with broken services.&lt;/p&gt;

&lt;p&gt;The root cause: the command worker's error channel wasn't being read in all code paths. If a command failed during the capture phase (reading stdout/stderr), the error was logged but not propagated to the result channel.&lt;/p&gt;

&lt;p&gt;The fix was straightforward propagate errors through every code path but finding it required adding 274 lines of tests to the worker queue and orchestrator. The bug was invisible because the services usually deployed fine. It only surfaced when a Docker build failed mid-stream and Pilum reported success anyway.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Lesson: if you're building a deployment tool, test the failure paths harder than the success paths. Nobody cares if deploys work. They care enormously when deploys claim to work but didn't.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  GCP Secrets Formatting
&lt;/h2&gt;

&lt;p&gt;GCP Cloud Run expects secrets in a specific format: &lt;code&gt;SECRET_NAME=projects/PROJECT/secrets/NAME/versions/latest&lt;/code&gt;. Pilum was passing them as &lt;code&gt;SECRET_NAME=value&lt;/code&gt;, which works for environment variables but not for secret references.&lt;/p&gt;

&lt;p&gt;Two fixes (#48, #58):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A config parser that detects whether a value is a secret reference or a literal, and formats accordingly.&lt;/li&gt;
&lt;li&gt;Environment variable handling that properly separates &lt;code&gt;--set-env-vars&lt;/code&gt; from &lt;code&gt;--set-secrets&lt;/code&gt; in the &lt;code&gt;gcloud run deploy&lt;/code&gt; command.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is the kind of bug that only shows up when you deploy a real service with real secrets, not when you test against mocked GCP APIs.&lt;/p&gt;




&lt;h2&gt;
  
  
  Cloudflare Pages: Two Fixes, Same Target
&lt;/h2&gt;

&lt;p&gt;Cloudflare Pages broke twice (#47, #56). The first fix was the recipe itself — the wrangler CLI changed its flags between versions and the recipe had hardcoded the old format.&lt;/p&gt;

&lt;p&gt;The second fix was more subtle: the capture worker was incorrectly detecting the end of output from the wrangler process. It would sometimes truncate the deployment URL from the output, so Pilum couldn't report where the site was deployed. The fix was a one-line change in how the capture worker detects stream completion, but it took an hour to diagnose because wrangler's output format is inconsistent between deploy targets.&lt;/p&gt;




&lt;h2&gt;
  
  
  YAML Parsing
&lt;/h2&gt;

&lt;p&gt;The YAML parser (#42) had an encoding issue. Users could write &lt;code&gt;build: go&lt;/code&gt; in their pilum.yaml, but the YAML library parsed &lt;code&gt;go&lt;/code&gt; as a boolean (&lt;code&gt;true&lt;/code&gt; in YAML 1.1). This broke the build step because it expected a string, not a boolean.&lt;/p&gt;

&lt;p&gt;Two-line fix: force string type on specific fields during parsing. But it's the kind of bug that makes you question every YAML-based config format you've ever designed.&lt;/p&gt;




&lt;h2&gt;
  
  
  npm Publishing: A Three-PR Saga
&lt;/h2&gt;

&lt;p&gt;Getting npm packages to publish correctly took three separate PRs (#52, #53, #57):&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;#52&lt;/strong&gt;: The npm ingredient needed workspace resolution. In a pnpm monorepo, &lt;code&gt;pnpm publish&lt;/code&gt; needs to know which workspace you're publishing. Added a &lt;code&gt;resolve_workspaces.js&lt;/code&gt; script that walks the workspace config and finds the right package.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;#53&lt;/strong&gt;: E2E test infrastructure. After #52 I realized I had no way to test the full publish flow without actually hitting the npm registry. Built a test harness with fixture packages and dry-run publishing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;#57&lt;/strong&gt;: &lt;code&gt;npm&lt;/code&gt; vs &lt;code&gt;pnpm&lt;/code&gt; command resolution. Some systems have &lt;code&gt;npm&lt;/code&gt; but not &lt;code&gt;pnpm&lt;/code&gt;, or vice versa. The completion and local command system needed to detect which package manager is available and use the right one.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  Features That Got Added Because We Needed Them
&lt;/h2&gt;

&lt;p&gt;The December launch had the core recipe engine and a few deploy targets. Everything below was added in February because real usage demanded it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Wave-based deployments&lt;/strong&gt; (#31): Services with dependencies deploy in order. The headline feature that later broke.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;npm recipe&lt;/strong&gt; (#32): Publish packages to npm. Required for the monorepo workflow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cloudflare Pages&lt;/strong&gt; (#34): Static site deployment. Every SID website uses this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Azure Container Apps&lt;/strong&gt; (#38): Because "multi-cloud" means more than just GCP.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GCP Cloud Run Jobs&lt;/strong&gt; (#30): For batch processing and migrations, not just long-running services.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;JSON output&lt;/strong&gt; (#36): For CI/CD integration. Parse deployment results programmatically.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;History and status commands&lt;/strong&gt; (#37, #40): "What did I deploy last? Is it still running?"&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment variable support&lt;/strong&gt; (#33): Pass variables to recipes at deploy time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recipes command&lt;/strong&gt; (#45): List available recipes and their ingredients.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GCP Cloud Run connections&lt;/strong&gt; (#55): VPC connectors, Cloud SQL connections, service-to-service auth.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Ten features in three weeks. Each one spawned 1-2 bug fixes in the following weeks.&lt;/p&gt;




&lt;h2&gt;
  
  
  The GitHub Action
&lt;/h2&gt;

&lt;p&gt;By February 9, Pilum was stable enough that I didn't want to keep writing &lt;code&gt;curl | sh&lt;/code&gt; install scripts in every CI workflow. So I built &lt;a href="https://github.com/SID-Technologies/Pilum-Action" rel="noopener noreferrer"&gt;pilum-action&lt;/a&gt; — a GitHub Action that installs Pilum and runs any command.&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="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;SID-Technologies/pilum-action@v1&lt;/span&gt;
  &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;command&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;deploy&lt;/span&gt;
    &lt;span class="na"&gt;tag&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.release.tag_name }}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It auto-detects the runner's OS and architecture, downloads the right binary, and passes through all credentials via environment variables. Simple wrapper, but it cut 15-20 lines of boilerplate from every deployment workflow.&lt;/p&gt;

&lt;p&gt;The action itself hit a bug immediately: the binary name in the release archive included the version number (&lt;code&gt;pilum-v0.3.1-linux-amd64&lt;/code&gt;), but the action was looking for &lt;code&gt;pilum&lt;/code&gt;. Two quick fixes (#3, #4) and a third one in March when the naming convention changed again.&lt;/p&gt;

&lt;p&gt;Every SID repo now uses &lt;code&gt;pilum-action@v1&lt;/code&gt; for deployments instead of raw shell scripts.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Website
&lt;/h2&gt;

&lt;p&gt;The &lt;a href="https://pilum.dev" rel="noopener noreferrer"&gt;pilum.dev&lt;/a&gt; website launched the same day as the CLI (December 3) — an Astro site with an animated terminal demo, deploy target icons, and full documentation.&lt;/p&gt;

&lt;p&gt;Over the next three months it evolved alongside the tool:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dec 5&lt;/strong&gt;: Automated tag updates — when Pilum releases a new version, the website automatically updates the version displayed in the terminal demo (#4)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Jan 3&lt;/strong&gt;: Full documentation pages — getting started, CLI reference, recipe guides (#5)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feb 7-8&lt;/strong&gt;: New provider icons as deploy targets were added to the CLI (#7, #8)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feb 11&lt;/strong&gt;: Documentation for all new features from the February sprint (#9)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feb 16&lt;/strong&gt;: Design improvements — the site went from "functional docs" to "looks like a real product" (#10)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Mar 19&lt;/strong&gt;: Proper favicons replacing the placeholder (#12)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The website was always the last thing updated after a feature shipped. In hindsight, having docs deploy automatically from the same &lt;code&gt;pilum deploy&lt;/code&gt; pipeline would have kept them in sync. That's a future improvement.&lt;/p&gt;




&lt;h2&gt;
  
  
  Security Hardening
&lt;/h2&gt;

&lt;p&gt;PR #51 was a comprehensive security audit response. The headline finding: &lt;strong&gt;shell injection via &lt;code&gt;pilum.yaml&lt;/code&gt; values.&lt;/strong&gt; If a service name or environment variable contained shell metacharacters (&lt;code&gt;$(rm -rf /)&lt;/code&gt;), they'd be passed unsanitized to &lt;code&gt;sh -c&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix introduced a &lt;code&gt;shellutil&lt;/code&gt; package with:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Quote()&lt;/code&gt;: POSIX single-quote escaping for string commands&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;ValidateServiceName()&lt;/code&gt;: Reject names with shell metacharacters&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;SanitizeHeredocValue()&lt;/code&gt;: Escape &lt;code&gt;$&lt;/code&gt;, backticks, and backslashes in heredoc values&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Also added: symlink traversal prevention, path validation, and safe temporary directory handling.&lt;/p&gt;

&lt;p&gt;Pilum's attack surface is small — users run their own configs — but in CI/CD environments where configs might be generated or templates might include untrusted input, these protections matter.&lt;/p&gt;

&lt;p&gt;Later, we added Grype + Syft vulnerability scanning to the CI pipeline (#51 and subsequent). Every dependency is scanned against the NVD database, and builds fail on high-severity CVEs.&lt;/p&gt;




&lt;h2&gt;
  
  
  The Numbers
&lt;/h2&gt;

&lt;p&gt;Four months of dogfooding (December 2025 — April 2026):&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;62 PRs merged&lt;/strong&gt; (17 features, 21 fixes, rest chore/docs)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Deploy targets used in production&lt;/strong&gt;: Cloud Run, Cloudflare Pages, npm, Homebrew (4 of 7)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Repos deploying with Pilum&lt;/strong&gt;: 6+ (platform-core, Torch, Statio, all websites, Pilum itself)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Orchestrator rewritten&lt;/strong&gt;: once (the 2000-line runner.go → modular architecture)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Security findings addressed&lt;/strong&gt;: 11&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Feb 7, 2025&lt;/strong&gt;: 8 features shipped in one day. Feb 8-12: 5 bugs found from that sprint.&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  What I'd Do Differently
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;E2E tests from day one.&lt;/strong&gt; Unit tests caught maybe 30% of the bugs on this list. Every serious bug — wave ordering, error swallowing, npm publishing, Cloudflare output — required the full pipeline to reproduce. I built the E2E framework in #53. Should have been #2.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Fewer features per sprint.&lt;/strong&gt; The Feb 7 feature marathon (8 features in 48 hours) created a week of bug fixes. Shipping 2-3 features with immediate dogfooding would have caught issues faster.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The orchestrator should have been modular from the start.&lt;/strong&gt; The 772-line &lt;code&gt;runner.go&lt;/code&gt; was unmaintainable. The rewrite into &lt;code&gt;execution.go&lt;/code&gt;, &lt;code&gt;pipeline.go&lt;/code&gt;, &lt;code&gt;steps.go&lt;/code&gt;, and &lt;code&gt;waves.go&lt;/code&gt; should have been the original architecture. The monolithic runner made every bug harder to find and every fix riskier.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;




&lt;h2&gt;
  
  
  What's Next
&lt;/h2&gt;

&lt;p&gt;Pilum is stable. It deploys everything SID Technologies builds. The immediate roadmap:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;More deploy targets as needed (Fly.io, Railway)&lt;/li&gt;
&lt;li&gt;Recipe marketplace for community contributions&lt;/li&gt;
&lt;li&gt;Better error messages (the #1 user experience issue)&lt;/li&gt;
&lt;li&gt;Pilum Cloud (hosted control plane, someday) — but that's a different product, not an open-source feature&lt;/li&gt;
&lt;/ul&gt;




&lt;h2&gt;
  
  
  The Takeaway
&lt;/h2&gt;

&lt;p&gt;The gap between "open-source announcement" and "tool I trust with production deploys" was about 40 pull requests. Every real deployment surfaced an edge case that unit tests never caught. The wave ordering bug was invisible for two months because most deploys don't have inter-service dependencies.&lt;/p&gt;

&lt;p&gt;If you're building developer tools: &lt;strong&gt;dogfood them immediately, not eventually.&lt;/strong&gt; The first month after launch is a second development phase. Budget for it.&lt;/p&gt;




&lt;h2&gt;
  
  
  Update: Seven Months In (July 2026)
&lt;/h2&gt;

&lt;p&gt;The most interesting thing that happened to Pilum since April is what stopped happening.&lt;/p&gt;

&lt;p&gt;The post above covers December through April: 62 pull requests, an orchestrator rewrite, a security audit, and a feature sprint that took a week of fixes to clean up. April through late July looks nothing like that. &lt;strong&gt;15 commits: three features, ten small fixes, a refactor, and a chore.&lt;/strong&gt; v0.6 to v0.7.4. No rewrites. No multi-PR bug sagas. Nothing new for the "everything that broke" list.&lt;/p&gt;

&lt;p&gt;Meanwhile the surface area kept growing. Pilum deploys everything SID Technologies ships: GCP services, every website, Homebrew formulas, and now private npm packages too. All of CI/CD runs through &lt;a href="https://github.com/SID-Technologies/Pilum-Action" rel="noopener noreferrer"&gt;pilum-action&lt;/a&gt;. I stopped thinking about the tool. For a deploy tool, that's the whole job.&lt;/p&gt;

&lt;p&gt;Two things from the quiet months are worth writing down.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The deploy config became the dev environment.&lt;/strong&gt; &lt;code&gt;pilum compose&lt;/code&gt; (#65) reads the same &lt;code&gt;pilum.yaml&lt;/code&gt; files that deploy to production and generates a &lt;code&gt;docker-compose.yml&lt;/code&gt;: services wired with &lt;code&gt;depends_on&lt;/code&gt;, healthchecks, and infrastructure dependencies declared once in &lt;code&gt;.pilum.yml&lt;/code&gt;. For most of my projects that means a Postgres container plus my services. It works really well. I use it constantly while developing, spinning containers up and down with the same configs that ship.&lt;/p&gt;

&lt;p&gt;The lesson came from the one project where it wasn't simple. I have a gateway service that routes to multiple backends, and a dependent project that lives in a different repo. I wanted all of those containers in one stack behind a single command, and I kept hitting port conflicts. The answer wasn't more generator features. Docker Compose already has an override convention: the generated &lt;code&gt;docker-compose.yml&lt;/code&gt; stays regenerable, and a hand-written &lt;code&gt;docker-compose.override.yml&lt;/code&gt; carries the extra containers and the port fixes. Compose merges the two automatically.&lt;/p&gt;

&lt;p&gt;There's a design principle in there: &lt;strong&gt;when your tool generates a file, decide on day one where human edits go. They're coming, and if the answer is "into the generated file," you built a one-way generator.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Features started arriving with their failure paths tested.&lt;/strong&gt; The one substantial feature of this stretch was Cloud Run sidecars plus two image-based recipes for deploying prebuilt images (#72). It shipped with its own e2e fixtures and an end-to-end test runner in the same PR. It spawned zero follow-up fixes. In February, a feature that size cost me two weeks of cleanup. In the "what I'd do differently" list above, I said the E2E framework should have been #2 instead of #53. Turns out following your own advice works.&lt;/p&gt;

&lt;p&gt;The February version of this post ends every section with a bug. The July version doesn't have the material. That's what stable looks like. It's also what boring looks like. Seven months in, I've decided they're the same thing.&lt;/p&gt;




&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/SID-Technologies/pilum" rel="noopener noreferrer"&gt;Pilum on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pilum.dev" rel="noopener noreferrer"&gt;pilum.dev&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://logicalbytes.dev/blog/introducing-pilum" rel="noopener noreferrer"&gt;Original announcement&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://romans.dev" rel="noopener noreferrer"&gt;Romans.dev — SID Technologies&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>go</category>
      <category>devops</category>
      <category>opensource</category>
      <category>infrastructure</category>
    </item>
    <item>
      <title>Managing Monorepos at Scale: Lessons from Meta, AWS, and a 15-Repo Migration</title>
      <dc:creator>Dan Flanagan</dc:creator>
      <pubDate>Mon, 27 Jul 2026 19:50:24 +0000</pubDate>
      <link>https://dev.to/logical_bytes/managing-monorepos-at-scale-lessons-from-meta-aws-and-a-15-repo-migration-o84</link>
      <guid>https://dev.to/logical_bytes/managing-monorepos-at-scale-lessons-from-meta-aws-and-a-15-repo-migration-o84</guid>
      <description>&lt;p&gt;Early in my career, I thought polyrepos were the way. Microservices, each in their own repo, independently deployable — it felt clean and modern. Then I joined Meta and worked in their massive monorepo, and it changed how I think about code organization entirely. Anything I needed to change, I could change it right there. Everything was discoverable. One commit could do everything I needed it to.&lt;/p&gt;

&lt;p&gt;Then I moved to AWS, back to polyrepo land, and the contrast was painful. Now I'm building my own products with monorepos, and I migrated SID Technologies from 15 separate repositories to one over a weekend.&lt;/p&gt;

&lt;p&gt;This post is about what I learned from each approach, the actual migration process with real metrics, and why the monorepo vs polyrepo debate is really an organizational question, not an engineering one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two Extremes: Meta vs AWS
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Meta's Monorepo
&lt;/h3&gt;

&lt;p&gt;At Meta, virtually everything lived in one Mercurial repository: Facebook, Instagram, WhatsApp web, internal tools, mobile apps, backend services. Thousands of engineers committing thousands of times per day.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What worked:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Cross-cutting changes were routine, not projects&lt;/li&gt;
&lt;li&gt;One engineer could touch iOS, Android, backend, and web in a single diff&lt;/li&gt;
&lt;li&gt;Refactoring was safe — your IDE could find all usages across the entire codebase&lt;/li&gt;
&lt;li&gt;No version management for internal code&lt;/li&gt;
&lt;li&gt;CI caught integration issues before merge&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What was hard:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Onboarding required downloading gigabytes of code&lt;/li&gt;
&lt;li&gt;Search was slow without custom tooling&lt;/li&gt;
&lt;li&gt;Build times required distributed caching (Buck/Bazel)&lt;/li&gt;
&lt;li&gt;Merge conflicts were frequent in hot files&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And then there was the dead code problem. Our team had this one legacy project — super old, not maintained anymore. We'd get the occasional errors on it and have to try to fix it. I eventually figured out we could deprecate it, and I created a commit deleting tens of thousands of lines of code. But there was this one function in one folder — some sort of hex translation function — and because this was ancient code in the main monorepo, it had over 200 references across dozens of different teams' folders. The obvious move was to delete everything around it and not touch that function.&lt;/p&gt;

&lt;p&gt;That's the monorepo tradeoff in one story: you can find everything, change everything, delete everything — but you also carry the responsibility of not breaking what other teams depend on. That said, dead code isn't a monorepo problem — it's a codebase problem. In a polyrepo company, you get entire repos that drift away and nobody even knows they're still running. At least in a monorepo you can see it.&lt;/p&gt;

&lt;h3&gt;
  
  
  AWS's Polyrepo
&lt;/h3&gt;

&lt;p&gt;At AWS, nearly every service lives in its own repository. Thousands of repositories, each with its own CI/CD, deployment pipeline, and ownership.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What works:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Clear ownership boundaries (one team, one repo)&lt;/li&gt;
&lt;li&gt;Independent deployment cadences&lt;/li&gt;
&lt;li&gt;Technology diversity (each team chooses their stack)&lt;/li&gt;
&lt;li&gt;Strong security isolation between services&lt;/li&gt;
&lt;li&gt;Small repositories are fast to clone and search&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;What's painful:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Discoverability is terrible. Half the time I'm using internal code search to find out how someone else did something and copy their code. I know people have rewritten CDK components all over the place because there's no standardization. The same infrastructure pattern gets implemented dozens of times by dozens of teams.&lt;/li&gt;
&lt;li&gt;Cross-cutting changes require coordination across dozens of repos&lt;/li&gt;
&lt;li&gt;Shared libraries mean version management hell&lt;/li&gt;
&lt;li&gt;Duplicate code accumulates across services&lt;/li&gt;
&lt;li&gt;Integration testing requires complex test environments&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Neither approach is wrong.&lt;/strong&gt; They fit their organizational constraints. Meta optimizes for velocity and coordination. AWS optimizes for autonomy and isolation. But here's the thing I've come to believe: polyrepo is fundamentally an organizational choice, not an engineering one. It's the same thing as setting CODEOWNERS and clear team boundaries in a monorepo — but now you're adding dependency injection on top.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Migration: 15 Repos to 1
&lt;/h2&gt;

&lt;p&gt;At SID, we started with the "best practice" of one repo per service. By the time we had 15 services, the overhead was crushing us.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Breaking Point
&lt;/h3&gt;

&lt;p&gt;I got tired of building cross-dependencies and injecting versions and libraries into repos. I really dislike GitHub submodules, and the constant issue of being pinned on a version because someone didn't update was eating my time. The immediate trigger was adding organization-level permissions — it required coordinated changes across 9 repositories. Two weeks of work. One week coding. One week dependency management.&lt;/p&gt;

&lt;p&gt;The pain points were clear:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Database models everywhere.&lt;/strong&gt; Our &lt;code&gt;User&lt;/code&gt; model was defined in 8 different repositories. When we added a field to the users table, we had to update 8 different type definitions, hope they stayed in sync, and debug runtime errors when they didn't.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Security fixes were nightmares.&lt;/strong&gt; Found a security bug in authentication middleware (its own repo): fix and release &lt;code&gt;auth-middleware@2.3.1&lt;/code&gt;, open PRs in 14 consuming repos, wait for 14 code reviews, merge and deploy 14 services, hope no one is still on the old version. For a security fix that should have been 10 minutes.&lt;/p&gt;

&lt;p&gt;I just called it. Time to migrate.&lt;/p&gt;

&lt;h3&gt;
  
  
  Saturday Morning: Structure Planning (3 hours)
&lt;/h3&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;sid-monorepo/
├── services/ # Each old repo becomes a directory
│ ├── authentication/
│ ├── billing/
│ └── ... # 15 services
├── pkg/ # Shared Go packages (consolidated)
├── packages/ # Shared TypeScript (consolidated)
├── db/ # Database models (single source of truth)
└── tools/ # Deployment scripts, CI tools

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Key decisions: services stay independently deployable, shared code moves to &lt;code&gt;pkg/&lt;/code&gt; and &lt;code&gt;packages/&lt;/code&gt; with no more versioning, database models get a single source of truth in &lt;code&gt;db/&lt;/code&gt;.&lt;/p&gt;

&lt;h3&gt;
  
  
  Saturday Afternoon: The Migration (8 hours)
&lt;/h3&gt;

&lt;p&gt;For each service, I cloned the old repo and moved contents into the monorepo structure:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone git@github.com:sid/authentication-service.git temp
&lt;span class="nb"&gt;mkdir&lt;/span&gt; &lt;span class="nt"&gt;-p&lt;/span&gt; services/authentication
&lt;span class="nb"&gt;mv &lt;/span&gt;temp/&lt;span class="k"&gt;*&lt;/span&gt; services/authentication/
git add services/authentication
git commit &lt;span class="nt"&gt;-m&lt;/span&gt; &lt;span class="s2"&gt;"Migrate authentication service"&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Did this for all 15 services. Took about 4 hours with breaks.&lt;/p&gt;

&lt;p&gt;Then came the hard part — consolidating shared code. I had 8 copies of the User model (slightly different), 5 copies of auth middleware (different versions), 3 copies of the Stripe client (different features). For each, I compared all versions, picked the most complete one, added missing features from the others, and moved it to &lt;code&gt;db/models/&lt;/code&gt; or &lt;code&gt;pkg/&lt;/code&gt;.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// Before: 8 different definitions across 8 repos&lt;/span&gt;
&lt;span class="c"&gt;// After: One definition&lt;/span&gt;
&lt;span class="c"&gt;// db/models/user.go&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;User&lt;/span&gt; &lt;span class="k"&gt;struct&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;ID&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UUID&lt;/span&gt;
    &lt;span class="n"&gt;Email&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;OrganizationID&lt;/span&gt; &lt;span class="n"&gt;uuid&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UUID&lt;/span&gt; &lt;span class="c"&gt;// This field was missing in 3 services&lt;/span&gt;
    &lt;span class="n"&gt;CreatedAt&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Time&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Sunday Morning: CI/CD (6 hours)
&lt;/h3&gt;

&lt;p&gt;Before: 15 separate GitHub Actions workflows, 3,200 lines of CI config total.&lt;/p&gt;

&lt;p&gt;After: one workflow with change detection:&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;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;CI&lt;/span&gt;
&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;push&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;pull_request&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;detect-changes&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;outputs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;services&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ steps.filter.outputs.services }}&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v3&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;dorny/paths-filter@v2&lt;/span&gt;
        &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;filter&lt;/span&gt;
        &lt;span class="na"&gt;with&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;filters&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
            &lt;span class="s"&gt;authentication:&lt;/span&gt;
              &lt;span class="s"&gt;- 'services/authentication/**'&lt;/span&gt;
              &lt;span class="s"&gt;- 'pkg/authentication/**'&lt;/span&gt;
              &lt;span class="s"&gt;- 'db/**'&lt;/span&gt;
            &lt;span class="s"&gt;billing:&lt;/span&gt;
              &lt;span class="s"&gt;- 'services/billing/**'&lt;/span&gt;
              &lt;span class="s"&gt;- 'pkg/stripe/**'&lt;/span&gt;
              &lt;span class="s"&gt;- 'db/**'&lt;/span&gt;

  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;detect-changes&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;needs.detect-changes.outputs.services != '[]'&lt;/span&gt;
    &lt;span class="na"&gt;strategy&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="na"&gt;matrix&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
        &lt;span class="na"&gt;service&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ fromJson(needs.detect-changes.outputs.services) }}&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;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v3&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Test ${{ matrix.service }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;cd services/${{ matrix.service }}&lt;/span&gt;
          &lt;span class="s"&gt;go test ./...&lt;/span&gt;

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Change &lt;code&gt;services/billing&lt;/code&gt;? Test only billing. Change &lt;code&gt;db/models&lt;/code&gt;? Test everything.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sunday Afternoon: Deployment and the Origin of Pilum
&lt;/h3&gt;

&lt;p&gt;This is where I hit a new problem. I had services that were Cloud Run containers, Cloud Run Jobs, Cloud Functions, and a frontend — all in the same repo. I didn't have a way to deploy all of these different service types with one tool. I didn't want 5 different infrastructure tools.&lt;/p&gt;

&lt;p&gt;At Plaid — also a monorepo — they had a ton of services and had built internal tooling around deployment that made it easy. I knew I wanted my services segmented and isolated, not a monolith, but I needed one deployment tool that understood different service types. So I started building &lt;a href="https://pilum.dev" rel="noopener noreferrer"&gt;Pilum&lt;/a&gt;, an open-source multi-cloud deployment orchestrator. That's its origin story — born from this exact migration.&lt;/p&gt;

&lt;h3&gt;
  
  
  Sunday Evening: The Bugs (3 hours)
&lt;/h3&gt;

&lt;p&gt;Deployed to staging. Found issues:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Circular dependency&lt;/strong&gt; : Service A imported Service B, Service B imported Service A. Hidden by versioning in polyrepo. Visible immediately in monorepo.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Import paths&lt;/strong&gt; : Missed some imports during migration.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Database migrations&lt;/strong&gt; : Had to reconcile conflicting migrations from different services.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Fixed each, re-deployed, verified. Total time: roughly 24 hours over a weekend. Could have been faster with better planning. Could have preserved git history if I'd been more careful — I skipped it for speed.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Results
&lt;/h2&gt;

&lt;p&gt;We tracked metrics for 3 months before and 3 months after:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Metric&lt;/th&gt;
&lt;th&gt;Polyrepo (15 repos)&lt;/th&gt;
&lt;th&gt;Monorepo&lt;/th&gt;
&lt;th&gt;Change&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Time to deploy all services&lt;/td&gt;
&lt;td&gt;45 min (serial)&lt;/td&gt;
&lt;td&gt;8 min (parallel)&lt;/td&gt;
&lt;td&gt;-82%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PRs for cross-cutting change&lt;/td&gt;
&lt;td&gt;8-15 PRs&lt;/td&gt;
&lt;td&gt;1 PR&lt;/td&gt;
&lt;td&gt;-90%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time to complete feature spanning 3 services&lt;/td&gt;
&lt;td&gt;10-14 days&lt;/td&gt;
&lt;td&gt;2-3 days&lt;/td&gt;
&lt;td&gt;-75%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Developer onboarding time&lt;/td&gt;
&lt;td&gt;2 days&lt;/td&gt;
&lt;td&gt;4 hours&lt;/td&gt;
&lt;td&gt;-75%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Lines of CI/CD config&lt;/td&gt;
&lt;td&gt;3,200 lines&lt;/td&gt;
&lt;td&gt;400 lines&lt;/td&gt;
&lt;td&gt;-87%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Time spent on dependency management&lt;/td&gt;
&lt;td&gt;4-6 hours/week&lt;/td&gt;
&lt;td&gt;0 hours/week&lt;/td&gt;
&lt;td&gt;-100%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bugs from version skew&lt;/td&gt;
&lt;td&gt;2-3 per month&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;td&gt;-100%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  The Real Polyrepo Pain Points
&lt;/h2&gt;

&lt;p&gt;I don't think polyrepo "fails" — I think it introduces specific friction that most startups don't need to pay for.&lt;/p&gt;

&lt;h3&gt;
  
  
  Discoverability
&lt;/h3&gt;

&lt;p&gt;Where does the code live? In a monorepo, you grep and find it. In polyrepo, you're searching across dozens of repositories, hoping the naming conventions are consistent, hoping the README is up to date. At AWS, I spend real time just finding how someone else solved a problem. In Meta's monorepo, I could find it in seconds.&lt;/p&gt;

&lt;h3&gt;
  
  
  Duplication Without Standardization
&lt;/h3&gt;

&lt;p&gt;Without a shared codebase, teams reinvent the same solutions independently. I've seen the same CDK patterns rewritten across dozens of teams at AWS. At SID, we had 8 copies of the User model. In a monorepo, there's one definition and everyone imports it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Version Pinning
&lt;/h3&gt;

&lt;p&gt;When &lt;code&gt;service-A&lt;/code&gt; depends on &lt;code&gt;shared-lib@1.0&lt;/code&gt; and &lt;code&gt;service-B&lt;/code&gt; depends on &lt;code&gt;shared-lib@2.0&lt;/code&gt;, and &lt;code&gt;service-C&lt;/code&gt; needs both — you have a diamond dependency problem. In a monorepo, this can't happen. Everyone is always on the same version.&lt;/p&gt;

&lt;h3&gt;
  
  
  The Organizational Argument
&lt;/h3&gt;

&lt;p&gt;Here's the thing: polyrepo gives you the same thing as CODEOWNERS and clear team boundaries in a monorepo. The isolation is real, but it's organizational isolation, not engineering isolation. You're paying for that isolation with dependency injection, version management, and coordination overhead. For a 5-person startup, that cost is pure waste. For Amazon with genuinely independent business units — AWS, Retail, Prime Video, Alexa — the isolation maps to real organizational boundaries and the cost is justified.&lt;/p&gt;

&lt;h2&gt;
  
  
  When Each Approach Wins
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Monorepo wins when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Team size is 1-50 engineers and everyone needs to coordinate&lt;/li&gt;
&lt;li&gt;Services share data models, auth, and infrastructure&lt;/li&gt;
&lt;li&gt;You need to iterate and refactor quickly&lt;/li&gt;
&lt;li&gt;You want one set of tooling, one CI config, one set of standards&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Polyrepo wins when:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Genuinely independent business units with different customers and roadmaps&lt;/li&gt;
&lt;li&gt;Different security/compliance requirements (HIPAA service vs marketing site)&lt;/li&gt;
&lt;li&gt;Open source components that need separate release cadences&lt;/li&gt;
&lt;li&gt;200+ engineers in truly autonomous teams&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The pattern across the industry:&lt;/strong&gt; Google, Meta, Microsoft, Stripe, and Uber run monorepos. Amazon, Netflix, and Spotify run polyrepos. Both models work. The difference is organizational structure, not engineering capability.&lt;/p&gt;

&lt;h2&gt;
  
  
  Common Objections
&lt;/h2&gt;

&lt;h3&gt;
  
  
  "Monorepos don't scale!"
&lt;/h3&gt;

&lt;p&gt;Google has 25,000+ engineers in a monorepo with billions of lines of code. They needed custom tooling (Bazel, CitC, Critique) — but the approach scales. Most startups will never hit those limits.&lt;/p&gt;

&lt;h3&gt;
  
  
  "A bug in shared code takes down everything!"
&lt;/h3&gt;

&lt;p&gt;In a monorepo, CI catches it before merge. In polyrepo, you catch it weeks later when services finally upgrade to the new version. The blast radius is actually smaller in a monorepo because you find and fix problems atomically.&lt;/p&gt;

&lt;p&gt;And honestly — most production incidents aren't from application code bugs. They're from configuration changes, database issues, and external dependency outages. The blast radius argument applies to maybe 10% of real incidents.&lt;/p&gt;

&lt;h3&gt;
  
  
  "Microservices need multiple repos!"
&lt;/h3&gt;

&lt;p&gt;Microservices are an architectural pattern. Repository structure is orthogonal. SID has 19 services in one repo. They deploy independently, scale independently, and have clear ownership. The monorepo is a development choice, not a deployment choice.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Evolution Path
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;0-10 engineers&lt;/strong&gt; : Monolith or 2-3 services in a monorepo. Don't overthink it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;10-50 engineers&lt;/strong&gt; : Natural service boundaries emerge. Split along team lines. The monorepo keeps coordination costs low.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;50-200 engineers&lt;/strong&gt; : Domain-driven design matters. Services map to business domains. Strong CODEOWNERS conventions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;200+ engineers&lt;/strong&gt; : You might consider polyrepo for genuinely independent business units. But Google has 25,000+ engineers in a monorepo, so don't assume you've hit scale limits.&lt;/p&gt;

&lt;p&gt;The mistake is treating the 200+ architecture as the starting point. Premature microservices are premature optimization.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Migration Advice
&lt;/h2&gt;

&lt;p&gt;If you're considering this:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Start small.&lt;/strong&gt; Pick 3-5 related services. Prove the value. Then expand.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Plan the structure.&lt;/strong&gt; Decide where shared packages, database models, and tools live. Document it in &lt;code&gt;CONTRIBUTING.md&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invest in CI.&lt;/strong&gt; Change detection is critical — your CI must detect which services changed and test only those (plus dependents). Budget time for this.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use CODEOWNERS.&lt;/strong&gt; Even in a small team, explicit ownership prevents the "everyone and no one owns this" problem.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Measure before and after.&lt;/strong&gt; Track deploy time, PRs for cross-cutting changes, dependency management hours. If you can't measure improvement, you can't justify the migration.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  The Monorepo Structure That Works
&lt;/h2&gt;

&lt;p&gt;Here's what SID looks like today — 19 services, growing:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;├── services/ # Independent microservices (19 total)
│ ├── authentication/ # User auth, OAuth, tokens
│ ├── billing/ # Stripe integration, subscriptions
│ ├── calendar/ # Calendar management
│ ├── kanban/ # Task boards
│ ├── notifications/ # Push, email, SMS
│ ├── organization/ # Team and org management
│ ├── permissions/ # RBAC, access control
│ └── ...
├── packages/ # Shared TypeScript packages
│ ├── api/ # Generated API clients
│ ├── configs/ # Shared ESLint, TS configs
│ ├── ui/ # Component library
│ └── utils/ # Common utilities
├── apps/ # Client applications
│ ├── web/ # Next.js web app
│ ├── desktop/ # Electron app
│ └── mobile/ # React Native
├── pkg/ # Shared Go packages (30+)
│ ├── authentication/ # Auth utilities
│ ├── middleware/ # HTTP middleware
│ ├── stripe/ # Billing integration
│ └── ...
└── db/ # Database schemas, migrations

&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Atomic changes. Shared code without versioning. Consistent tooling. Easy refactoring. One commit can touch the API, the web app, and a backend service — single PR, single code review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Conclusion
&lt;/h2&gt;

&lt;p&gt;The monorepo vs polyrepo debate isn't about technology — it's about how your organization communicates. Conway's Law still holds: your system architecture will mirror your communication structure.&lt;/p&gt;

&lt;p&gt;For most startups with 1-50 engineers and high coordination needs, monorepo wins. You'll ship faster, refactor safely, and spend zero time on dependency management. For large organizations with genuinely independent business units, polyrepo maps to real organizational boundaries.&lt;/p&gt;

&lt;p&gt;I went from polyrepo fan to monorepo convert over the course of my career — Meta showed me what was possible, AWS reminded me what the alternative felt like, and the SID migration proved it was worth the weekend.&lt;/p&gt;

&lt;p&gt;Your first step: count how many PRs last month touched multiple repositories or required coordinated releases. If it's more than a handful, you're paying the coordination tax daily. The monorepo conversation is worth having.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Further reading:&lt;/em&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://cacm.acm.org/research/why-google-stores-billions-of-lines-of-code-in-a-single-repository/" rel="noopener noreferrer"&gt;Why Google Stores Billions of Lines of Code in a Single Repository&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://monorepo.tools/" rel="noopener noreferrer"&gt;Monorepo Tools&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://trunkbaseddevelopment.com/" rel="noopener noreferrer"&gt;Trunk Based Development&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/microsoft/VFSForGit" rel="noopener noreferrer"&gt;Microsoft's VFS for Git&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.uber.com/blog/go-monorepo-bazel/" rel="noopener noreferrer"&gt;Uber's Monorepo Journey&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://pilum.dev" rel="noopener noreferrer"&gt;Pilum: Open Source Deployment Orchestrator&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>devops</category>
      <category>architecture</category>
      <category>software</category>
      <category>learning</category>
    </item>
  </channel>
</rss>
