<?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: Hung 1M</title>
    <description>The latest articles on DEV Community by Hung 1M (@hung1m).</description>
    <link>https://dev.to/hung1m</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%2F111069%2Fcd658682-49da-42a1-9c64-7794a2c7d8ee.jpeg</url>
      <title>DEV Community: Hung 1M</title>
      <link>https://dev.to/hung1m</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/hung1m"/>
    <language>en</language>
    <item>
      <title>One Go codebase, two deploy modes: a monolith that becomes microservices without a rewrite</title>
      <dc:creator>Hung 1M</dc:creator>
      <pubDate>Tue, 14 Jul 2026 06:11:47 +0000</pubDate>
      <link>https://dev.to/hung1m/one-go-codebase-two-deploy-modes-a-monolith-that-becomes-microservices-without-a-rewrite-3jan</link>
      <guid>https://dev.to/hung1m/one-go-codebase-two-deploy-modes-a-monolith-that-becomes-microservices-without-a-rewrite-3jan</guid>
      <description>&lt;p&gt;Every new backend starts with the same fork in the road, and both paths hurt.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Start with microservices&lt;/strong&gt; and you pay for distributed systems on day one — network hops, eventual consistency, five things to deploy — before you have the traffic or the team to justify any of it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start with a monolith&lt;/strong&gt; and you move fast, right up until the day you need to split it and discover the boundaries were never real. The "modules" all reach into each other's tables, and the "quick extraction" becomes a quarter-long rewrite.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I got tired of choosing wrong, so I built a starter that refuses to make me choose: &lt;strong&gt;&lt;a href="https://github.com/hung1m/go-simplescale" rel="noopener noreferrer"&gt;Go-SimpleScale&lt;/a&gt;&lt;/strong&gt; — three example services (users, products, orders) that deploy as &lt;strong&gt;one binary or three independent services from the same code&lt;/strong&gt;. You switch by &lt;em&gt;how you run it&lt;/em&gt;, not by editing anything.&lt;/p&gt;

&lt;p&gt;This post is about the one design decision that makes that possible, plus the "boring" fundamentals I think a starter has no excuse to get wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  The third path: a modular monolith with &lt;em&gt;real&lt;/em&gt; boundaries
&lt;/h2&gt;

&lt;p&gt;The trick isn't clever. It's a discipline the language makes easy:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;A service depends on the interfaces it needs — never on other services.&lt;/strong&gt;&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Take the order service. It needs to know whether a user exists, what a product costs, and it needs to reserve stock. So it defines &lt;em&gt;its own&lt;/em&gt; interfaces describing exactly that — and nothing else:&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;// services/order/application/deps.go&lt;/span&gt;
&lt;span class="k"&gt;package&lt;/span&gt; &lt;span class="n"&gt;application&lt;/span&gt;

&lt;span class="c"&gt;// The order service owns these interfaces. It does not import the user or&lt;/span&gt;
&lt;span class="c"&gt;// product packages — it imports the *shape* of what it needs from them.&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;UserService&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Exists&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;userID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kt"&gt;bool&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="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;ProductService&lt;/span&gt; &lt;span class="k"&gt;interface&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;Price&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;productID&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Money&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;ReserveStock&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;productID&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;qty&lt;/span&gt; &lt;span class="kt"&gt;int&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now there are two implementations of those interfaces, and the entry point picks one at startup:&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;// Monolith: wire the real in-process services — a direct method call.&lt;/span&gt;
&lt;span class="n"&gt;orderApp&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;application&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;userService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;     &lt;span class="c"&gt;// *user.Service&lt;/span&gt;
    &lt;span class="n"&gt;productService&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;  &lt;span class="c"&gt;// *product.Service&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c"&gt;// Microservices: wire HTTP clients that satisfy the same interfaces —&lt;/span&gt;
&lt;span class="c"&gt;// now it's a network call, and the order code never knows the difference.&lt;/span&gt;
&lt;span class="n"&gt;orderApp&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;application&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;userclient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UserServiceURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APIKey&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;productclient&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProductServiceURL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;APIKey&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;That's the whole idea:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Monolith:       order ──(Go interface)──▶ in-process user/product services
Microservices:  order ──(Go interface)──▶ HTTP clients ──▶ user/product APIs
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Because the transport is the &lt;em&gt;only&lt;/em&gt; thing that changes, the monolith and the standalone services expose the &lt;strong&gt;same routes with the same authentication&lt;/strong&gt;. Clients don't change when you split. Moving a service out becomes an operational decision — a Compose profile, a Deployment — instead of a rewrite.&lt;/p&gt;

&lt;h2&gt;
  
  
  One codebase, two ways to run it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# Start here: one binary, all services, in-memory event bus, one database&lt;/span&gt;
make secrets            &lt;span class="c"&gt;# generate .env — the app refuses to boot without it&lt;/span&gt;
make docker-up-monolith

&lt;span class="c"&gt;# Later, when you actually need it: user :8081, product :8082, order :8083, Kafka&lt;/span&gt;
make docker-up-services
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same &lt;code&gt;/api/v1&lt;/code&gt; routes, same auth, either way. The event bus follows the same pattern as the service calls — one interface, backed by an in-memory bus in the monolith and Kafka in microservices mode, so the publish/subscribe code is identical on both sides.&lt;/p&gt;

&lt;h2&gt;
  
  
  The unglamorous stuff a starter shouldn't punt on
&lt;/h2&gt;

&lt;p&gt;Anyone can wire up three services. What separates a starter you can &lt;em&gt;build on&lt;/em&gt; from a toy is whether the boring, critical things are correct by default. I treated that as the whole point:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Auth in every mode.&lt;/strong&gt; Every API route requires a valid API key or JWT — in the monolith &lt;em&gt;and&lt;/em&gt; in each standalone service. Secrets are &lt;strong&gt;required at startup with no fallback&lt;/strong&gt;: the binaries won't boot without a real &lt;code&gt;JWT_SECRET&lt;/code&gt;. That kills the single most common way public templates get their deployments forged — the well-known dev secret nobody replaced.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Authorization, not just authentication.&lt;/strong&gt; Orders belong to the authenticated caller. You can't read or cancel someone else's order, and prices come from the product catalog, never the request body:&lt;br&gt;
&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;  &lt;span class="c"&gt;// Client-supplied prices are ignored; the server prices from the catalog.&lt;/span&gt;
  &lt;span class="n"&gt;price&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;products&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Price&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;item&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProductID&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Correct concurrency.&lt;/strong&gt; Stock is reserved with a single atomic statement, so two people racing for the last unit can't both win — the classic oversell bug, closed at the database:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;  &lt;span class="k"&gt;UPDATE&lt;/span&gt; &lt;span class="n"&gt;products&lt;/span&gt; &lt;span class="k"&gt;SET&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
  &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;stock&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;   &lt;span class="c1"&gt;-- affects 0 rows if it would go negative&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Real middleware, not stubs.&lt;/strong&gt; Rate limiting, request logging, panic recovery, CORS with &lt;code&gt;Vary: Origin&lt;/code&gt;, timeouts, and body-size limits are implemented and tested — not placeholder functions that quietly do nothing.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Tests where the risk is.&lt;/strong&gt; Coverage concentrates on the parts that bite — auth, the event bus (race-detector clean), order pricing/stock/ownership — rather than chasing a coverage number.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest about the trade-offs
&lt;/h2&gt;

&lt;p&gt;A starter that pretends it's production-perfect is lying to you. The README ships a "Honest Limitations" section on purpose. A few:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Money is &lt;code&gt;float64&lt;/code&gt;&lt;/strong&gt; — fine for a demo, wrong for real payments (switch to integer minor units first).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stock compensation is best-effort&lt;/strong&gt; — a crash mid-order can leak a reservation; the real fix is an outbox/saga, deliberately out of scope.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The in-memory rate limiter and event bus are per-process&lt;/strong&gt; — multiple replicas need Redis/Kafka equivalents.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Those aren't bugs; they're the seams where &lt;em&gt;your&lt;/em&gt; production hardening goes. Naming them is part of the design.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I wanted it to demonstrate
&lt;/h2&gt;

&lt;p&gt;Pragmatic architecture judgment: most teams don't need microservices yet, but you can build the seams so they're a config change away when they do — and get auth, secrets, and concurrency right &lt;em&gt;before&lt;/em&gt; piling on features.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Code (start here):&lt;/strong&gt; &lt;a href="https://github.com/hung1m/go-simplescale" rel="noopener noreferrer"&gt;github.com/hung1m/go-simplescale&lt;/a&gt; — clone it, run &lt;code&gt;make secrets &amp;amp;&amp;amp; make docker-up-monolith&lt;/code&gt;, and hit &lt;code&gt;http://localhost:8080&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The design write-up&lt;/strong&gt; with the architecture diagram: &lt;a href="https://hungnguyen.tech/projects/go-simplescale" rel="noopener noreferrer"&gt;my projects page&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Companion project&lt;/strong&gt; that takes the async side all the way (saga, outbox, idempotency, dead-letter queues): &lt;a href="https://github.com/hung1m/event-driven-order-saga" rel="noopener noreferrer"&gt;event-driven-order-saga&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you've lived through a painful monolith-to-microservices split, I'd love to hear what actually broke for you — the boundaries, the data, or the org. That's the part no starter can solve for you.&lt;/p&gt;

</description>
      <category>go</category>
      <category>microservices</category>
      <category>architecture</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
