<?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: Antonio Towami</title>
    <description>The latest articles on DEV Community by Antonio Towami (@towami).</description>
    <link>https://dev.to/towami</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%2F4052077%2Fc7f9a462-5a7d-4387-b4ab-9ae5580c1e11.png</url>
      <title>DEV Community: Antonio Towami</title>
      <link>https://dev.to/towami</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/towami"/>
    <language>en</language>
    <item>
      <title>Background Jobs in Go with Asynq: Emails, Notifications, and Resilience</title>
      <dc:creator>Antonio Towami</dc:creator>
      <pubDate>Thu, 15 Oct 2026 17:58:00 +0000</pubDate>
      <link>https://dev.to/towami/background-jobs-in-go-with-asynq-emails-notifications-and-resilience-4a17</link>
      <guid>https://dev.to/towami/background-jobs-in-go-with-asynq-emails-notifications-and-resilience-4a17</guid>
      <description>&lt;p&gt;When a customer places an order on a merchant's store, the checkout path does &lt;strong&gt;not&lt;/strong&gt; call an SMTP server. After saving the order, it asks a mailer interface to enqueue a task in Redis.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Separate the Worker
&lt;/h2&gt;

&lt;p&gt;Our &lt;code&gt;worker&lt;/code&gt; binary is a dedicated process that pulls tasks from Redis queues using &lt;a href="https://github.com/hibiken/asynq" rel="noopener noreferrer"&gt;Asynq&lt;/a&gt;. It runs independently from the three HTTP binaries (&lt;code&gt;site&lt;/code&gt;, &lt;code&gt;shops&lt;/code&gt;, &lt;code&gt;admin&lt;/code&gt;).&lt;/p&gt;

&lt;p&gt;The reasoning is simple: SMTP latency is variable, and transient failures need retries. Neither concern should block the HTTP response. The worker can retry independently if the mail server is temporarily unavailable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Task Architecture
&lt;/h2&gt;

&lt;p&gt;Email delivery uses one generic task type and a template discriminator:&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;const&lt;/span&gt; &lt;span class="n"&gt;TypeEmailDelivery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"email:deliver"&lt;/span&gt;

&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;EmailPayload&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;To&lt;/span&gt;       &lt;span class="kt"&gt;string&lt;/span&gt;         &lt;span class="s"&gt;`json:"to"`&lt;/span&gt;
    &lt;span class="n"&gt;Lang&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;         &lt;span class="s"&gt;`json:"lang"`&lt;/span&gt;
    &lt;span class="n"&gt;Template&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;         &lt;span class="s"&gt;`json:"template"`&lt;/span&gt;
    &lt;span class="n"&gt;Data&lt;/span&gt;     &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&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;any&lt;/span&gt; &lt;span class="s"&gt;`json:"data"`&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The producer marshals the payload and checks both operations:&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;payload&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;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Marshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EmailPayload&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;To&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;       &lt;span class="n"&gt;recipient&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Lang&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;     &lt;span class="n"&gt;lang&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Template&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"new_shop_order"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;Data&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;     &lt;span class="k"&gt;map&lt;/span&gt;&lt;span class="p"&gt;[&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;any&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"OrderID"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="n"&gt;orderID&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;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;return&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;task&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;asynq&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewTask&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TypeEmailDelivery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;payload&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;_&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;client&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EnqueueContext&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;task&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="no"&gt;nil&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 worker handles it:&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;EmailHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ProcessTask&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;t&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;asynq&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Task&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="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="n"&gt;EmailPayload&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="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Unmarshal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Payload&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;p&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="no"&gt;nil&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;"decode email payload: %v: %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="n"&gt;asynq&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SkipRetry&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;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sender&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Send&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;p&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;Malformed JSON is permanent, so &lt;code&gt;SkipRetry&lt;/code&gt; is appropriate. SMTP/network errors are returned normally and remain retryable.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Lives in the Worker
&lt;/h2&gt;

&lt;p&gt;Beyond order emails, the worker handles:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Email delivery&lt;/strong&gt; — sign-up codes, password resets, support mail, orders, and inquiries&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Telegram notifications&lt;/strong&gt; — queued reports and alerts&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Marketplace imports&lt;/strong&gt; — longer, low-priority ingestion tasks&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each task type is registered with its own handler. The worker also uses weighted &lt;code&gt;critical&lt;/code&gt;, &lt;code&gt;default&lt;/code&gt;, and &lt;code&gt;low&lt;/code&gt; queues. Those weights bias which queued task is selected next; they do not reserve workers, so long-running imports can still occupy the whole pool.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Dev Gotcha
&lt;/h2&gt;

&lt;p&gt;During local development, a queued email will not be sent unless both Redis and the worker are running. Use the project's documented service and log commands rather than copying production hostnames or credentials into an article.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;docker compose up &lt;span class="nt"&gt;-d&lt;/span&gt; worker
docker compose logs worker
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the worker is down while Redis remains available, pending tasks can be processed after the worker restarts. That is not the same as guaranteed durability: data loss still depends on Redis persistence, replication, eviction policy, and backup configuration.&lt;/p&gt;

&lt;h2&gt;
  
  
  Retry Policy
&lt;/h2&gt;

&lt;p&gt;Asynq retries failed tasks according to its retry policy. Short notification tasks use the standard policy, while imports run in a lower-priority queue with bounded retries and execution time. After retries are exhausted, Asynq archives the task. Monitor queue latency, retry counts, and archived tasks with tooling such as &lt;a href="https://github.com/hibiken/asynqmon" rel="noopener noreferrer"&gt;Asynqmon&lt;/a&gt; or your own metrics.&lt;/p&gt;

&lt;p&gt;Asynq provides &lt;strong&gt;at-least-once&lt;/strong&gt; execution, not exactly-once execution. A worker can complete a side effect and fail before acknowledging the task, so handlers must be idempotent. For email, a provider idempotency key or a database send ledger can prevent unwanted duplicates.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Just goroutines?
&lt;/h2&gt;

&lt;p&gt;A bare &lt;code&gt;go func() { sendEmail() }()&lt;/code&gt; has three structural problems:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Lost tasks on restart.&lt;/strong&gt; A deploy kills the process and all in-flight goroutines die.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No retry.&lt;/strong&gt; If SMTP is down for 5 minutes, all emails from that window are lost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No visibility.&lt;/strong&gt; A bare goroutine provides no queue, failure, or processing state to inspect.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Redis + Asynq gives us durable-enough queuing, retries, scheduling, and observability with little application code. The trade-offs are another stateful dependency and at-least-once semantics.&lt;/p&gt;

&lt;p&gt;One final boundary matters: a database commit and a queue publication are separate operations unless the design explicitly bridges them. For business-critical delivery, use a transactional outbox in the same database transaction and publish it asynchronously.&lt;/p&gt;

&lt;p&gt;Task payloads may contain email addresses or order details. Keep Redis on a private network with authentication and encryption as appropriate, minimize payloads and retention, and never log full payloads.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is based on lessons from building &lt;a href="https://cr.towami.com" rel="noopener noreferrer"&gt;Towami&lt;/a&gt;, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>redis</category>
      <category>architecture</category>
      <category>backend</category>
    </item>
    <item>
      <title>Our Layered REST Architecture in Go: Why GET Must Not Mutate Application State</title>
      <dc:creator>Antonio Towami</dc:creator>
      <pubDate>Fri, 09 Oct 2026 23:50:00 +0000</pubDate>
      <link>https://dev.to/towami/our-layered-rest-architecture-in-go-why-get-must-not-mutate-application-state-1839</link>
      <guid>https://dev.to/towami/our-layered-rest-architecture-in-go-why-get-must-not-mutate-application-state-1839</guid>
      <description>&lt;p&gt;We have a hard rule in our codebase: &lt;strong&gt;GET handlers never mutate application state.&lt;/strong&gt; Incidental server-side effects such as access logging are compatible with HTTP's definition of a safe method, but a GET must not create, update, or delete an application resource. This matters even more when TLS 1.3 early data is enabled.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 0-RTT Replay Problem
&lt;/h2&gt;

&lt;p&gt;We use Cloudflare with &lt;a href="https://developers.cloudflare.com/speed/optimization/protocol/0-rtt-connection-resumption/" rel="noopener noreferrer"&gt;0-RTT Connection Resumption&lt;/a&gt; enabled. TLS 1.3 early data can reduce latency for a resumed connection, but early data does not have inherent replay protection. An attacker may capture and replay it.&lt;/p&gt;

&lt;p&gt;Cloudflare supports 0-RTT for GET, HEAD, and OPTIONS requests and marks them with &lt;code&gt;Early-Data: 1&lt;/code&gt;. That safe-method restriction reduces the surface; it does not make a side-effecting GET safe. A GET that increments a counter, creates a record, or marks a notification as read may run more than once.&lt;/p&gt;

&lt;p&gt;The broader rule comes from &lt;a href="https://www.rfc-editor.org/rfc/rfc9110#section-9.2.1" rel="noopener noreferrer"&gt;RFC 9110's safe-method semantics&lt;/a&gt;: clients, crawlers, caches, and prefetchers may invoke GET without expecting state changes. 0-RTT is one reason to respect that contract, not the only reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Our Layered Approach
&lt;/h2&gt;

&lt;p&gt;Layering and REST are separate ideas; this is the dependency flow we use for HTTP requests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;router → middleware → handler → service → repository → model/DB
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each layer has a clear role:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Layer&lt;/th&gt;
&lt;th&gt;Responsibility&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Router&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;URL → handler mapping&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Middleware&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Auth, country, locale, CSRF, rate limiting&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Handler&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Parse request, call service, return response&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Service&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Business logic, validation&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Repository&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;SQL/GORM queries&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;strong&gt;Model&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;Database entity definitions&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;By convention, handlers do not import repositories directly and services do not construct SQL. Go does not enforce these boundaries automatically, so import checks, tests, and review are still useful. The payoff is that storage details can change without rewriting HTTP concerns.&lt;/p&gt;

&lt;h2&gt;
  
  
  Practical Enforcement
&lt;/h2&gt;

&lt;p&gt;GET handlers depend on query-oriented service interfaces:&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;CatalogQueries&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;List&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;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;CatalogFilter&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;([]&lt;/span&gt;&lt;span class="n"&gt;CatalogCard&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;func&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;h&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;CatalogHandler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;gin&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="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;filter&lt;/span&gt; &lt;span class="n"&gt;CatalogFilter&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="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ShouldBindQuery&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;filter&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="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AbortWithStatusJSON&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;StatusBadRequest&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;H&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s"&gt;"error"&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"invalid filters"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;cards&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;h&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;queries&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;List&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&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;filter&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;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AbortWithStatus&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;StatusInternalServerError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;JSON&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;StatusOK&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cards&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;Publishing, editing, and deleting use POST, PUT, PATCH, or DELETE. Cloudflare does not accept those methods as 0-RTT early data, but the method alone does not prevent duplicates: clients and proxies can retry requests, and users can double-submit. Important mutations still need transactions, uniqueness constraints, or idempotency keys.&lt;/p&gt;

&lt;h2&gt;
  
  
  DTO Separation
&lt;/h2&gt;

&lt;p&gt;We split transport types by application context. A card view should expose only what the page needs:&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;CatalogCard&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="kt"&gt;uint&lt;/span&gt;
    &lt;span class="n"&gt;Title&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Slug&lt;/span&gt;      &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Price&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Currency&lt;/span&gt;  &lt;span class="kt"&gt;string&lt;/span&gt;
    &lt;span class="n"&gt;Image&lt;/span&gt;     &lt;span class="kt"&gt;string&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repository also uses an explicit projection for list queries instead of loading large text and JSONB columns. This reduces transferred and decoded data, although the actual gain must be measured against the real query and dataset rather than assumed.&lt;/p&gt;

&lt;h2&gt;
  
  
  What This Architecture Helps Prevent
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Accidental side effects on safe routes&lt;/strong&gt; — GET stays safe under replay, crawling, and prefetching&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scattered business rules&lt;/strong&gt; — mutations have one service-level entry point&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Over-fetching&lt;/strong&gt; — explicit repository projections make selected columns reviewable&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Transport/storage coupling&lt;/strong&gt; — HTTP types do not have to mirror database models&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;It is more boilerplate than a flat handler-does-everything approach. The rule is worth it because it makes behavior easier to test and reason about. It is not, by itself, complete replay protection: mutation endpoints still require their own idempotency and authorization controls.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is based on lessons from building &lt;a href="https://towami.com" rel="noopener noreferrer"&gt;Towami&lt;/a&gt;, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>rest</category>
      <category>architecture</category>
      <category>security</category>
    </item>
    <item>
      <title>Serving Tenant Storefronts on Custom Domains with Go + Cloudflare for SaaS</title>
      <dc:creator>Antonio Towami</dc:creator>
      <pubDate>Sun, 04 Oct 2026 06:00:00 +0000</pubDate>
      <link>https://dev.to/towami/serving-tenant-storefronts-on-custom-domains-with-go-cloudflare-for-saas-539p</link>
      <guid>https://dev.to/towami/serving-tenant-storefronts-on-custom-domains-with-go-cloudflare-for-saas-539p</guid>
      <description>&lt;p&gt;A merchant storefront can live on a platform subdomain (&lt;code&gt;mystore.platform.example&lt;/code&gt;) or a custom domain (&lt;code&gt;www.store.example&lt;/code&gt;). Both options are served by the same Go application. Here is how we resolve which shop to render.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Shop Host Middleware
&lt;/h2&gt;

&lt;p&gt;When a request hits the &lt;code&gt;shops&lt;/code&gt; binary, a middleware extracts the &lt;code&gt;Host&lt;/code&gt; header and figures out which shop it belongs to:&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="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ShopHostMiddleware&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Resolve&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;gin&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;return&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;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;gin&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="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;requestHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&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="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;host&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isApexHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;c&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="k"&gt;return&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;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;subdomainSlug&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;ok&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;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;isCountry&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;helper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Countries&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt; &lt;span class="n"&gt;isCountry&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AbortWithStatus&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;StatusNotFound&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;shop&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;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resolveShop&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&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;host&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;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AbortWithStatus&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;StatusInternalServerError&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;return&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;shop&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;m&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;isShopSubdomain&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;host&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AbortWithStatus&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;StatusNotFound&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="n"&gt;c&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="c"&gt;// not a configured custom hostname&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ShopContextKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shop&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;c&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;resolveShop&lt;/code&gt; tries two strategies:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Subdomain lookup&lt;/strong&gt;: extracts &lt;code&gt;mystore&lt;/code&gt; from &lt;code&gt;mystore.platform.example&lt;/code&gt; and looks up that unique slug.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Custom-domain lookup&lt;/strong&gt;: normalizes the supported &lt;code&gt;www.store.example&lt;/code&gt; hostname to the stored root, &lt;code&gt;store.example&lt;/code&gt;, and looks it up through a unique index.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Country subdomains are explicitly excluded because they belong to the marketplace application. We parse hostnames with &lt;code&gt;net.SplitHostPort&lt;/code&gt;, lowercase them, and validate them against known suffixes. If a reverse proxy supplies &lt;code&gt;X-Forwarded-Host&lt;/code&gt;, trust it only from a proxy you control.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cloudflare for SaaS: The SSL Part
&lt;/h2&gt;

&lt;p&gt;The hardest part of custom domains is not routing but certificate issuance and renewal. We use &lt;a href="https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/create-custom-hostnames/" rel="noopener noreferrer"&gt;Cloudflare for SaaS custom hostnames&lt;/a&gt; for that lifecycle.&lt;/p&gt;

&lt;p&gt;The setup:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;We configure a fallback origin such as &lt;code&gt;connect.platform.example&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;When a merchant adds a custom domain, our app calls the Cloudflare API to create a Custom Hostname&lt;/li&gt;
&lt;li&gt;The merchant sets &lt;code&gt;CNAME www → connect.platform.example&lt;/code&gt; at their DNS provider&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Our Custom Hostname request uses HTTP DCV. Once the &lt;code&gt;www&lt;/code&gt; CNAME routes through Cloudflare, Cloudflare can validate the hostname and issue the certificate for &lt;code&gt;www.store.example&lt;/code&gt;. Cloudflare retries failed validation on a backoff schedule. A status &lt;code&gt;GET&lt;/code&gt; only observes that process; if we need an immediate recheck after DNS is fixed, the documented API flow is to &lt;code&gt;PATCH&lt;/code&gt; the hostname with its current SSL settings.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why www-Only
&lt;/h2&gt;

&lt;p&gt;We only support &lt;code&gt;www.{domain}&lt;/code&gt;, not bare apex domains. A conventional CNAME cannot coexist with the other records required at a DNS zone apex. Some DNS providers offer ALIAS/ANAME or CNAME flattening, but support and behavior vary. The merchant therefore configures an HTTP redirect from &lt;code&gt;store.example&lt;/code&gt; to &lt;code&gt;www.store.example&lt;/code&gt; with their DNS or registrar provider.&lt;/p&gt;

&lt;p&gt;This is a product constraint, not a universal Cloudflare limitation. Cloudflare for SaaS also documents &lt;a href="https://developers.cloudflare.com/cloudflare-for-platforms/cloudflare-for-saas/domain-support/hostname-validation/#apex-proxying" rel="noopener noreferrer"&gt;apex proxying&lt;/a&gt;, but its prerequisites and plan availability should be checked before committing to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Status Tracking
&lt;/h2&gt;

&lt;p&gt;For each shop we store:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the provider's opaque Custom Hostname ID&lt;/li&gt;
&lt;li&gt;a local status: &lt;code&gt;pending&lt;/code&gt;, &lt;code&gt;active&lt;/code&gt;, or &lt;code&gt;error&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;a provider status or error summary&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;When a merchant connects a domain, we create or find the Cloudflare hostname, store its ID, and map Cloudflare's hostname and SSL states to our smaller status set. The dashboard can explicitly refresh that state. A scheduled poller is another option, but the current implementation does not depend on one.&lt;/p&gt;

&lt;p&gt;The API token stays server-side and is never returned to the browser; production credentials should have only the required zone permissions. Provider error text is untrusted. Our templates HTML-escape it, but a stronger boundary is to log detailed provider errors server-side and show merchants a bounded, generic message.&lt;/p&gt;

&lt;h2&gt;
  
  
  What We Learned
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Define one canonical hostname.&lt;/strong&gt; In our case, input may include &lt;code&gt;www.&lt;/code&gt;, but storage uses the root and serving uses &lt;code&gt;www.&lt;/code&gt;. This rule must be consistent in validation, provisioning, and lookup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use reserved example domains in documentation.&lt;/strong&gt; A wildcard under a local &lt;code&gt;.test&lt;/code&gt; domain is enough to exercise host routing without Cloudflare.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Index both lookup keys.&lt;/strong&gt; Host resolution is on the request path, so subdomain slugs and custom domains need unique indexes. Add caching only with an explicit invalidation strategy.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Make provisioning idempotent.&lt;/strong&gt; Before creating a hostname, look it up; retries should not produce duplicate provider resources.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Custom domains are table stakes for many SaaS storefront products. Cloudflare for SaaS made it possible without running our own ACME client or certificate renewal infrastructure.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is based on lessons from building &lt;a href="https://towami.com" rel="noopener noreferrer"&gt;Towami&lt;/a&gt;, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>cloudflare</category>
      <category>saas</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Multi-Host Routing in Go: One Binary, Many Domains</title>
      <dc:creator>Antonio Towami</dc:creator>
      <pubDate>Tue, 29 Sep 2026 06:00:00 +0000</pubDate>
      <link>https://dev.to/towami/multi-host-routing-in-go-one-binary-many-domains-56bh</link>
      <guid>https://dev.to/towami/multi-host-routing-in-go-one-binary-many-domains-56bh</guid>
      <description>&lt;p&gt;Our marketplace is organized around multiple country sites. Each country gets its own subdomain, while the apex is the cross-country portal. A single Go application serves the portal and country catalogs.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Middleware Approach
&lt;/h2&gt;

&lt;p&gt;Instead of path-based routing (&lt;code&gt;/cr/en/vehicles&lt;/code&gt;), we use host-based resolution. A Gin middleware extracts the country from the &lt;code&gt;Host&lt;/code&gt; header before any route handler fires:&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;CountryHostMiddleware&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;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;gin&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;return&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;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;gin&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="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;helper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CountrySlugFromHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Host&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;ok&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&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;CountryContextKey&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;OnCountryHostContextKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;c&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;CountrySlugFromHost&lt;/code&gt; strips the port, lowercases the host, and accepts only a one-label subdomain in the country allowlist. If &lt;code&gt;cr.example.com&lt;/code&gt; comes in, the context gets &lt;code&gt;country=cr&lt;/code&gt;. Edge routing sends shop hosts to a different application; this middleware must not treat an arbitrary subdomain as a country.&lt;/p&gt;

&lt;p&gt;A later locale middleware derives language preferences and puts both country and language into the request context. Currency is resolved separately because a visitor may choose a display currency that differs from the country's default.&lt;/p&gt;

&lt;h2&gt;
  
  
  URL Structure: Language in Path, Country in Host
&lt;/h2&gt;

&lt;p&gt;Each country host serves localized URLs such as &lt;code&gt;/{lang}/vehicles&lt;/code&gt; and &lt;code&gt;/{lang}/real-estate&lt;/code&gt;. The host's default-language homepage lives at &lt;code&gt;/&lt;/code&gt;; requesting the redundant default-language homepage returns a 301 to &lt;code&gt;/&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;This design gives us clean hreflang tags for Google:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight html"&gt;&lt;code&gt;&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"en-CR"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://cr.example.com/en/vehicles"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"es-CR"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://cr.example.com/es/vehiculos"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;span class="nt"&gt;&amp;lt;link&lt;/span&gt; &lt;span class="na"&gt;rel=&lt;/span&gt;&lt;span class="s"&gt;"alternate"&lt;/span&gt; &lt;span class="na"&gt;hreflang=&lt;/span&gt;&lt;span class="s"&gt;"x-default"&lt;/span&gt; &lt;span class="na"&gt;href=&lt;/span&gt;&lt;span class="s"&gt;"https://cr.example.com/es/vehiculos"&lt;/span&gt; &lt;span class="nt"&gt;/&amp;gt;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The Cookie Problem
&lt;/h2&gt;

&lt;p&gt;Authentication is shared across the platform's apex and country subdomains. The session cookie uses &lt;code&gt;Domain=example.com&lt;/code&gt;, so the browser sends it to both &lt;code&gt;example.com&lt;/code&gt; and its subdomains. A leading dot is accepted by browsers but ignored by modern cookie semantics, so &lt;code&gt;Domain=.example.com&lt;/code&gt; does not create a different scope.&lt;/p&gt;

&lt;p&gt;That scope is also a security boundary. A compromised sibling subdomain may be able to shadow or overwrite a domain cookie, and &lt;code&gt;SameSite&lt;/code&gt; does not isolate sibling subdomains because they are the same site. We therefore share the cookie only with trusted platform hosts and set &lt;code&gt;Secure&lt;/code&gt;, &lt;code&gt;HttpOnly&lt;/code&gt;, and an appropriate &lt;code&gt;SameSite&lt;/code&gt; value. Tenant-owned custom domains cannot share this cookie.&lt;/p&gt;

&lt;p&gt;If cross-subdomain login is unnecessary, a host-only cookie is safer and can use the &lt;code&gt;__Host-&lt;/code&gt; prefix. That prefix is incompatible with a &lt;code&gt;Domain&lt;/code&gt; attribute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Legacy URL Migration
&lt;/h2&gt;

&lt;p&gt;We originally had country paths on the apex: &lt;code&gt;example.com/cr/en/vehicles&lt;/code&gt;. When we moved to subdomains, we needed 301 redirects:&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;LegacyCountryRedirectMiddleware&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;config&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Config&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;gin&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;return&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;c&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;gin&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="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;helper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OnCountryHost&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;c&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="k"&gt;return&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;target&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;helper&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LegacyCountryPathRedirect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&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="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&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;RawQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;cfg&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;""&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Redirect&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;StatusMovedPermanently&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;target&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Abort&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="n"&gt;c&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="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Old links such as &lt;code&gt;example.com/cr/en/vehicles&lt;/code&gt; now redirect permanently to &lt;code&gt;cr.example.com/en/vehicles&lt;/code&gt;. We also preserve the query string deliberately and test malformed country/language combinations so the redirect cannot become an open redirect.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Result
&lt;/h2&gt;

&lt;p&gt;One binary and one router serve the portal and multiple country catalogs. Adding a country still requires registry, locale, currency, SEO, and data changes, but it does not require a new service architecture.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is based on lessons from building &lt;a href="https://cr.towami.com" rel="noopener noreferrer"&gt;Towami&lt;/a&gt;, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>saas</category>
    </item>
    <item>
      <title>How We Run 4 Services From One Go Monorepo</title>
      <dc:creator>Antonio Towami</dc:creator>
      <pubDate>Thu, 24 Sep 2026 21:21:09 +0000</pubDate>
      <link>https://dev.to/towami/how-we-run-4-services-from-one-go-monorepo-5dlm</link>
      <guid>https://dev.to/towami/how-we-run-4-services-from-one-go-monorepo-5dlm</guid>
      <description>&lt;p&gt;We're building Towami — a commerce platform that combines white-label online stores with country marketplaces. One codebase produces four long-running services, each with a separate entry point and responsibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Four Binaries
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;cmd/
├── site/     → marketplace portal + country catalogs
├── shops/    → merchant storefronts on custom domains
├── admin/    → back-office panel
└── worker/   → background jobs via Asynq (no HTTP)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;site&lt;/code&gt; serves the main domain and multiple country subdomains. &lt;code&gt;shops&lt;/code&gt; serves merchant storefronts on platform subdomains and custom domains. &lt;code&gt;admin&lt;/code&gt; is the back-office dashboard. &lt;code&gt;worker&lt;/code&gt; processes email, Telegram, and import tasks through Redis queues.&lt;/p&gt;

&lt;p&gt;The repository also has an importer command. It is a CLI utility rather than a long-running service, so it is outside the four in the title.&lt;/p&gt;

&lt;h2&gt;
  
  
  Shared &lt;code&gt;internal/&lt;/code&gt; With Clean Boundaries
&lt;/h2&gt;

&lt;p&gt;All four binaries import from a single &lt;code&gt;internal/&lt;/code&gt; package tree:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;router → middleware → handler → service → repository → model/DB
                        ↘ templates (Templ)
                        ↘ dto
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The key is sub-packaging by application: &lt;code&gt;handler/site/&lt;/code&gt;, &lt;code&gt;handler/shops/&lt;/code&gt;, and &lt;code&gt;handler/admin/&lt;/code&gt;, with the same split for services, repositories, DTOs, and templates. Each binary imports only the packages it needs. The worker, for example, does not import HTTP handlers or templates.&lt;/p&gt;

&lt;p&gt;Go's &lt;code&gt;internal&lt;/code&gt; directory also prevents code outside the parent module tree from importing these packages. It does not enforce our layer order, though; that remains an architectural convention backed by review and tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why Not Microservices?
&lt;/h2&gt;

&lt;p&gt;Early on, we considered splitting into separate repos. Three things stopped us:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Shared models.&lt;/strong&gt; Products, shops, and users are the same entities everywhere. Duplicating model definitions across repos means sync hell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Atomic refactors.&lt;/strong&gt; When we change a database column, the handler, service, repository, DTO, and template all update in one commit.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deployment simplicity.&lt;/strong&gt; Four builds with &lt;code&gt;CGO_ENABLED=0&lt;/code&gt; produce self-contained binaries that can be managed as independent service units. We do not need container orchestration, a service mesh, or versioned APIs between these cooperating applications.&lt;/p&gt;

&lt;p&gt;This is still a modular monolith: the binaries share code and data contracts. Separate executables alone do not make them microservices.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Gotcha: Air and Hot Reload
&lt;/h2&gt;

&lt;p&gt;In development, each service runs in Docker with &lt;a href="https://github.com/air-verse/air" rel="noopener noreferrer"&gt;Air&lt;/a&gt; for hot reload. Without scoped watch rules, an edit to shared code can rebuild every service.&lt;/p&gt;

&lt;p&gt;We solved this with scoped watch configs. The &lt;code&gt;worker&lt;/code&gt; Air config ignores &lt;code&gt;templates/&lt;/code&gt;, &lt;code&gt;handler/&lt;/code&gt;, and &lt;code&gt;router/&lt;/code&gt;. The &lt;code&gt;admin&lt;/code&gt; config ignores &lt;code&gt;handler/site/&lt;/code&gt; and &lt;code&gt;handler/shops/&lt;/code&gt;. Each service only rebuilds when files relevant to it change.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="c"&gt;# Worker: exclude HTTP and UI layers&lt;/span&gt;
&lt;span class="nn"&gt;[build]&lt;/span&gt;
  &lt;span class="py"&gt;exclude_dir&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s"&gt;"internal/templates"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"internal/handler"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"internal/router"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"assets"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s"&gt;"storage"&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;
  
  
  Bottom Line
&lt;/h2&gt;

&lt;p&gt;A Go monorepo with one &lt;code&gt;cmd/&lt;/code&gt; entry point per service gives us process-level separation while keeping atomic refactors and one &lt;code&gt;go.mod&lt;/code&gt;. It is a good fit while the applications share a schema and are maintained by one team. If independent ownership or deployment becomes the real constraint, the boundary can change later.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Next up: how one of those binaries serves multiple country sites on different subdomains with a single process.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;&lt;em&gt;This article is based on lessons from building &lt;a href="https://towami.com" rel="noopener noreferrer"&gt;Towami&lt;/a&gt;, a multi-country marketplace and white-label storefront platform. Follow for more practical notes on Go, HTMX, infrastructure, and SaaS.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>architecture</category>
      <category>webdev</category>
      <category>saas</category>
    </item>
  </channel>
</rss>
