<?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: rock288</title>
    <description>The latest articles on DEV Community by rock288 (@rock288).</description>
    <link>https://dev.to/rock288</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.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F372116%2F319dc38f-fca4-4a49-9c04-8534716d913b.jpeg</url>
      <title>DEV Community: rock288</title>
      <link>https://dev.to/rock288</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rock288"/>
    <language>en</language>
    <item>
      <title>After 5 years of Go services, here's the boilerplate I wish existed</title>
      <dc:creator>rock288</dc:creator>
      <pubDate>Sun, 17 May 2026 11:18:06 +0000</pubDate>
      <link>https://dev.to/rock288/after-5-years-of-go-services-heres-the-boilerplate-i-wish-existed-2pen</link>
      <guid>https://dev.to/rock288/after-5-years-of-go-services-heres-the-boilerplate-i-wish-existed-2pen</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;TL;DR:&lt;/strong&gt; I open-sourced &lt;a href="https://github.com/rock288/go-mongo-boilerplate" rel="noopener noreferrer"&gt;&lt;code&gt;rock288/go-mongo-boilerplate&lt;/code&gt;&lt;/a&gt; — a Go 1.25 service template that ships the boring production stuff (observability, retry, DLQ, SSRF-safe HTTP, health splits, graceful shutdown) so you don't write it for the 7th time. Click "Use this template" and start with &lt;code&gt;make scaffold name=Order&lt;/code&gt;.&lt;/p&gt;
&lt;/blockquote&gt;




&lt;h2&gt;
  
  
  The problem with every Go boilerplate I've used
&lt;/h2&gt;

&lt;p&gt;I've started six Go services in the last five years. The first 200 lines were always the same:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Wire up a &lt;code&gt;slog&lt;/code&gt; handler that auto-injects &lt;code&gt;trace_id&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Pick a config library (koanf? viper? envconfig?) and write a &lt;code&gt;Validate()&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;Add Mongo + an &lt;code&gt;event.CommandMonitor&lt;/code&gt; that doesn't leak memory&lt;/li&gt;
&lt;li&gt;Build a Kafka consumer with retry topic + DLQ&lt;/li&gt;
&lt;li&gt;Hand-roll graceful shutdown for two consumer goroutines&lt;/li&gt;
&lt;li&gt;Realize the HTTP client is wide-open to SSRF and patch the dialer&lt;/li&gt;
&lt;li&gt;Split &lt;code&gt;/healthz&lt;/code&gt; (liveness) from &lt;code&gt;/readyz&lt;/code&gt; (readiness)&lt;/li&gt;
&lt;li&gt;Spend an afternoon making golangci-lint, mockery, and Wire all happy together&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Most Go boilerplates on GitHub solve &lt;strong&gt;one&lt;/strong&gt; of these. None of the ones I found solved all of them in a way that felt opinionated rather than kitchen-sink-y.&lt;/p&gt;

&lt;p&gt;So I wrote one. Then I wrote it again. Then I extracted the third iteration into &lt;a href="https://github.com/rock288/go-mongo-boilerplate" rel="noopener noreferrer"&gt;a public template&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;This post is a tour of the parts that took me the most rewrites to get right — and the trade-offs I'd argue with you about.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. Package-by-feature + a tiny &lt;code&gt;platform/&lt;/code&gt; layer
&lt;/h2&gt;

&lt;p&gt;The most common boilerplate mistake: &lt;code&gt;models/&lt;/code&gt;, &lt;code&gt;controllers/&lt;/code&gt;, &lt;code&gt;services/&lt;/code&gt; directories. That layout scales to maybe four features before you grep for &lt;code&gt;User&lt;/code&gt; and get 14 files in 7 packages.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;internal/
  user/                 ← one feature, all in one place
    model.go
    repository.go
    service.go
    handler.go
    dto.go
    routes.go
    errors.go
    wire.go
  role/                 ← another feature
  platform/             ← cross-feature infrastructure
    config/  logger/  database/  httpserver/  httpclient/
    kafka/   sqs/      health/   observability/  resilience/
    pagination/         httperror/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hard rule: features call each other only through exported interfaces (&lt;code&gt;UserService&lt;/code&gt;, not &lt;code&gt;userService&lt;/code&gt;). Cross-feature dependencies on concrete types are a code smell. Lint rule on this would be nice — open to suggestions.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;internal/platform/&lt;/code&gt; layer is everything that doesn't belong to a single feature. Any feature may import platform; features may &lt;strong&gt;not&lt;/strong&gt; import each other's &lt;code&gt;internal&lt;/code&gt; symbols.&lt;/p&gt;

&lt;p&gt;Adding a new feature is one command:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make scaffold &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Order
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A bash script clones &lt;code&gt;internal/user/&lt;/code&gt;, runs &lt;code&gt;perl&lt;/code&gt; to rename identifiers, and prints the wire-up checklist. From &lt;code&gt;make scaffold&lt;/code&gt; to a working &lt;code&gt;GET /orders&lt;/code&gt; endpoint is ~10 minutes.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. MongoDB v2 driver — and why you probably haven't migrated yet
&lt;/h2&gt;

&lt;p&gt;The &lt;code&gt;go.mongodb.org/mongo-driver/v2&lt;/code&gt; GA'd, but most templates on GitHub are still on v1. The v2 driver has a cleaner cursor API, generics support, and — crucially for production — there's still &lt;strong&gt;no official OTel instrumentation package&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;So I wrote a custom &lt;code&gt;event.CommandMonitor&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;// internal/platform/database/mongo_otel.go&lt;/span&gt;
&lt;span class="n"&gt;client&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;mongo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Client&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;ApplyURI&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;uri&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;
    &lt;span class="n"&gt;SetMonitor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;NewCommandMonitor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The monitor keeps a &lt;code&gt;RequestID → span&lt;/code&gt; map populated on &lt;code&gt;CommandStarted&lt;/code&gt; and consumed on &lt;code&gt;CommandSucceeded&lt;/code&gt; / &lt;code&gt;CommandFailed&lt;/code&gt;. The catch: if a command never completes (network drop, server restart) the entry leaks. So there's a janitor goroutine that purges entries older than 2 minutes.&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;// Sketch — full impl in the repo&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;CommandMonitor&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;inflight&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;Map&lt;/span&gt;  &lt;span class="c"&gt;// RequestID → spanEntry&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;m&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;CommandMonitor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Started&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;evt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;span&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;tracer&lt;/span&gt;&lt;span class="o"&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;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"mongo."&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CommandName&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;inflight&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Store&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequestID&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;spanEntry&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="p"&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="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;m&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;CommandMonitor&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;Succeeded&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;evt&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;v&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;inflight&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;LoadAndDelete&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;evt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequestID&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;v&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;span&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;End&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;Hot take: every project I've seen that uses MongoDB in Go has at least one production memory leak in their tracing layer. Mine probably did too until I wrote that janitor.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Kafka retry + DLQ that actually works under load
&lt;/h2&gt;

&lt;p&gt;This is the part of the template I've rewritten the most times across jobs.&lt;/p&gt;

&lt;p&gt;Rules I now live by:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Three topics per logical event&lt;/strong&gt;: &lt;code&gt;events&lt;/code&gt;, &lt;code&gt;events.retry&lt;/code&gt;, &lt;code&gt;events.dlq&lt;/code&gt;. No magic, no in-memory queues.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Retry topic has its own consumer group&lt;/strong&gt; (&lt;code&gt;&amp;lt;group&amp;gt;-retry&lt;/code&gt;). When it polls a message, it sleeps &lt;code&gt;min(base * 2^n, max)&lt;/code&gt; before republishing to the main topic. This separates retry latency from main-topic throughput.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;x-retry-count&lt;/code&gt; and &lt;code&gt;x-error-reason&lt;/code&gt; are headers, not body fields&lt;/strong&gt;. The body is opaque to the platform.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Inbound &lt;code&gt;x-retry-count&lt;/code&gt; and &lt;code&gt;x-error-reason&lt;/code&gt; are stripped on republish&lt;/strong&gt;. Attacker can't pre-set retry count to force DLQ. (Spotted on a real pentest. Yes, it was me.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Idempotency key auto-generated&lt;/strong&gt; on publish if not provided. Consumers dedupe via this key, not message offset.
&lt;/li&gt;
&lt;/ol&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// The contract the platform exposes&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;MessageHandler&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;Handle&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;record&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;kgo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Record&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="c"&gt;// Return:&lt;/span&gt;
&lt;span class="c"&gt;//   nil                                  → commit offset (ACK)&lt;/span&gt;
&lt;span class="c"&gt;//   errors.Is(err, context.Canceled)     → no commit, no republish (shutdown)&lt;/span&gt;
&lt;span class="c"&gt;//   errors.Is(err, kafka.ErrNonRetryable) → straight to DLQ&lt;/span&gt;
&lt;span class="c"&gt;//   any other error                      → retry topic with incremented count&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;W3C &lt;code&gt;traceparent&lt;/code&gt; is injected on the producer side and extracted on the consumer side so distributed traces don't break when crossing the broker.&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;producer&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="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"user-events"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;body&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="c"&gt;//                  ↑&lt;/span&gt;
&lt;span class="c"&gt;//                  span context auto-propagated into the record header&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The failure path calls &lt;code&gt;ForceSample()&lt;/code&gt; so retry/DLQ traffic always shows up on the dashboard — sampling shouldn't hide errors.&lt;/p&gt;




&lt;h2&gt;
  
  
  4. SQS as a second broker — same API, same routing
&lt;/h2&gt;

&lt;p&gt;Plenty of teams pick SQS over Kafka when they're AWS-only and don't need replay history. So the template ships both, side-by-side, with deliberately identical routing semantics:&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;// Kafka&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;kafka&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MessageHandler&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;Handle&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="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;kgo&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Record&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="c"&gt;// SQS — same shape&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;sqs&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MessageHandler&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;Handle&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="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;types&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Message&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="c"&gt;// Same return contract:&lt;/span&gt;
&lt;span class="c"&gt;//   nil → ACK&lt;/span&gt;
&lt;span class="c"&gt;//   ctx.Canceled → drop&lt;/span&gt;
&lt;span class="c"&gt;//   ErrNonRetryable → DLQ&lt;/span&gt;
&lt;span class="c"&gt;//   other err → retry queue with backoff&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The same &lt;code&gt;make scaffold&lt;/code&gt; walkthrough works for both. The &lt;code&gt;cmd/worker&lt;/code&gt; binary runs four goroutines via &lt;code&gt;errgroup.WithContext&lt;/code&gt; (Kafka main+retry, SQS main+retry); first non-nil cancels the shared ctx and all consumers shut down together.&lt;/p&gt;

&lt;p&gt;Gotchas the docs warn you about:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;SQS &lt;code&gt;MessageDelaySeconds&lt;/code&gt; caps at &lt;strong&gt;900s&lt;/strong&gt;. Backoff &amp;gt; 15 min silently clamps. (For longer backoffs you need an external scheduler.)&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MessageAttributes&lt;/code&gt; limit is &lt;strong&gt;10 keys&lt;/strong&gt;; 3 are reserved (&lt;code&gt;traceparent&lt;/code&gt;, &lt;code&gt;tracestate&lt;/code&gt;, &lt;code&gt;x-idempotency-key&lt;/code&gt;). Producer rejects with &lt;code&gt;ErrTooManyAttributes&lt;/code&gt; if the caller exceeds the budget.&lt;/li&gt;
&lt;li&gt;Standard SQS does NOT guarantee ordering — don't expect Kafka-partition semantics.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;LocalStack for dev:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;make localstack-up
make sqs-create-queues   &lt;span class="c"&gt;# provisions events / events-retry / events-dlq + RedrivePolicy&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Production: queues are provisioned by your IaC (Terraform/CDK). The worker only needs &lt;code&gt;sqs:Send/Receive/Delete/GetQueueUrl/ChangeMessageVisibility&lt;/code&gt; — no &lt;code&gt;:CreateQueue&lt;/code&gt;. Boilerplate ships infra code only; you wire your own &lt;code&gt;sqs.MessageHandler&lt;/code&gt;.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Compile-time DI with &lt;code&gt;google/wire&lt;/code&gt; — once you try it you can't go back
&lt;/h2&gt;

&lt;p&gt;Runtime DI containers feel clever until you spend two hours debugging "nil pointer" because container key resolution failed at startup.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;google/wire&lt;/code&gt; generates the wiring at compile time. If a provider is missing, the build fails. There's no reflection, no runtime overhead, and the generated file is grep-able.&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;//go:build wireinject&lt;/span&gt;

&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;InitializeServer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cfgPath&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="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;ServerApp&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="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="n"&gt;wire&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Build&lt;/span&gt;&lt;span class="p"&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;Load&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ProvideMongoConfig&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;database&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewClient&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;ProviderSet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;           &lt;span class="c"&gt;// one line wires the whole feature&lt;/span&gt;
        &lt;span class="n"&gt;role&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProviderSet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;ProvideRouter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;wire&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Struct&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ServerApp&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="s"&gt;"*"&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="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="no"&gt;nil&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;make wire&lt;/code&gt; regenerates &lt;code&gt;wire_gen.go&lt;/code&gt;. CI fails on drift — so a contributor adding a feature without running &lt;code&gt;make wire&lt;/code&gt; gets blocked.&lt;/p&gt;

&lt;p&gt;The trade-off: there's a learning curve. But after the first feature, every subsequent addition follows the same &lt;code&gt;ProviderSet&lt;/code&gt; pattern and Claude Code (or any agent) can copy it.&lt;/p&gt;

&lt;p&gt;Which brings me to —&lt;/p&gt;




&lt;h2&gt;
  
  
  6. The vibe-coding angle: &lt;code&gt;CLAUDE.md&lt;/code&gt; + &lt;code&gt;AGENTS.md&lt;/code&gt;
&lt;/h2&gt;

&lt;p&gt;If you've used Claude Code, Cursor, Aider, or Windsurf agent mode, you know they all read the repo for context. Most repos give them nothing useful — agent ends up cargo-culting whatever pattern it finds first.&lt;/p&gt;

&lt;p&gt;I committed two files specifically for AI agents:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;CLAUDE.md&lt;/code&gt;&lt;/strong&gt; (215 lines) — commands, architecture, coding conventions, package boundaries, "Adding a feature" checklist&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;AGENTS.md&lt;/code&gt;&lt;/strong&gt; — multi-agent compat redirect (Cursor and Aider both read this convention)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight markdown"&gt;&lt;code&gt;&lt;span class="gu"&gt;## Coding Conventions&lt;/span&gt;

&lt;span class="gs"&gt;**Naming**&lt;/span&gt;
&lt;span class="p"&gt;-&lt;/span&gt; Interfaces: no &lt;span class="sb"&gt;`I`&lt;/span&gt; prefix. &lt;span class="sb"&gt;`UserService`&lt;/span&gt; (interface) / &lt;span class="sb"&gt;`userService`&lt;/span&gt; (impl)
&lt;span class="p"&gt;-&lt;/span&gt; Constructors &lt;span class="sb"&gt;`NewUserService`&lt;/span&gt; return the interface
&lt;span class="p"&gt;-&lt;/span&gt; Errors: sentinel &lt;span class="sb"&gt;`ErrXxx`&lt;/span&gt; in feature-local &lt;span class="sb"&gt;`errors.go`&lt;/span&gt;
...

&lt;span class="gu"&gt;## Adding an SQS-consuming feature&lt;/span&gt;
&lt;span class="p"&gt;
1.&lt;/span&gt; Create &lt;span class="sb"&gt;`internal/&amp;lt;feature&amp;gt;/event_handler_sqs.go`&lt;/span&gt; implementing &lt;span class="sb"&gt;`sqs.MessageHandler`&lt;/span&gt;
&lt;span class="p"&gt;2.&lt;/span&gt; Replace &lt;span class="sb"&gt;`ProvideSQSHandler`&lt;/span&gt; in &lt;span class="sb"&gt;`cmd/worker/providers.go`&lt;/span&gt;
&lt;span class="p"&gt;3.&lt;/span&gt; Add &lt;span class="sb"&gt;`&amp;lt;feature&amp;gt;.NewSQSEventHandler`&lt;/span&gt; to &lt;span class="sb"&gt;`cmd/worker/wire.go`&lt;/span&gt; ProviderSet
&lt;span class="p"&gt;4.&lt;/span&gt; &lt;span class="sb"&gt;`make wire mocks test`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The payoff is real: I can paste a Linear ticket into Claude Code, and it produces a 80%-correct PR because it knows the repo's conventions, the test patterns, and which file to edit. The agent doesn't waste context discovering — the discovery is one file.&lt;/p&gt;

&lt;p&gt;This is a controversial design choice. Some people don't want their repo "locked into" Claude. But &lt;code&gt;CLAUDE.md&lt;/code&gt; is just markdown — re-readable by humans, ignorable by anyone who doesn't use AI. Cost: 215 lines. Benefit: agents move from "best-guess" to "follows your conventions".&lt;/p&gt;




&lt;h2&gt;
  
  
  7. The boring stuff you wish someone had written for you
&lt;/h2&gt;

&lt;p&gt;Things in the template you'd otherwise reinvent on day 4 of your project:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;/healthz&lt;/code&gt; (liveness) vs &lt;code&gt;/readyz&lt;/code&gt; (readiness)&lt;/strong&gt; with cached checkers and fail-after-N hysteresis so a transient blip doesn't flap your load balancer.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;SSRF-safe HTTP client&lt;/strong&gt; (&lt;code&gt;httpclient.NewExternalClient&lt;/code&gt;) — custom &lt;code&gt;Dialer&lt;/code&gt; rejects loopback, RFC1918, link-local, and AWS metadata IPs (&lt;code&gt;169.254.169.254&lt;/code&gt;). The default for any non-hardcoded URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;http.Server&lt;/code&gt; timeouts&lt;/strong&gt; wired correctly (&lt;code&gt;ReadHeaderTimeout&lt;/code&gt;, &lt;code&gt;ReadTimeout&lt;/code&gt;, &lt;code&gt;WriteTimeout&lt;/code&gt;, &lt;code&gt;IdleTimeout&lt;/code&gt;) — these CANNOT be replaced by handler-level timeouts because body read happens before the handler.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Middleware ordering&lt;/strong&gt; documented with rationale:
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  otelgin → Recovery → RequestID → RequestLogger → RateLimit → BodyLimit → Timeout → CORS → handler
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cursor pagination&lt;/strong&gt; as a platform-level helper — opaque base64(JSON) cursor, generic &lt;code&gt;Page[T]&lt;/code&gt; envelope, &lt;code&gt;ClampLimit&lt;/code&gt; capped at 100.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shared error envelope&lt;/strong&gt; &lt;code&gt;{error: {code, message}}&lt;/code&gt; via &lt;code&gt;httperror.BadRequest(c, err)&lt;/code&gt; so handlers don't redefine &lt;code&gt;gin.H{"error": err.Error()}&lt;/code&gt; 47 times.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;X-Request-ID&lt;/code&gt;&lt;/strong&gt; propagated through &lt;code&gt;ctx&lt;/code&gt;, response headers, and &lt;code&gt;slog&lt;/code&gt; fields — debug correlation from client log → server trace.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Distroless Docker&lt;/strong&gt; with healthcheck via the binary's &lt;code&gt;healthcheck&lt;/code&gt; subcommand (because distroless has no curl/wget).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;make ci&lt;/code&gt;&lt;/strong&gt; runs &lt;code&gt;lint + test + race + govulncheck + gitleaks&lt;/code&gt;. The CI workflow blocks merges on any of these.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these are revolutionary. Together they save you the first two sprints.&lt;/p&gt;




&lt;h2&gt;
  
  
  What's NOT in the template (and why)
&lt;/h2&gt;

&lt;p&gt;I'm allergic to "kitchen sink" boilerplates. Things deliberately excluded:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth / AuthZ&lt;/strong&gt; — every team has different requirements (session vs JWT, OPA vs Casbin vs hand-rolled). Adding any choice is a wrong choice for 80% of users.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Integration tests with testcontainers&lt;/strong&gt; — go-kit philosophy: build the integration harness when you have a real feature that needs it, not on speculation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CQRS / event sourcing&lt;/strong&gt; — demand-driven. If your domain needs it, you know.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;API versioning gymnastics&lt;/strong&gt; (&lt;code&gt;v1/&lt;/code&gt;, &lt;code&gt;v2/&lt;/code&gt;) — the boilerplate has &lt;code&gt;/api/v1&lt;/code&gt; baked in; multi-version is the user's call.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;GraphQL&lt;/strong&gt; — different concern.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Frontend&lt;/strong&gt; — wrong repo.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you need any of these, the template is a starting point, not a finished product.&lt;/p&gt;




&lt;h2&gt;
  
  
  How to use 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;# 1. Click "Use this template" on GitHub, or:&lt;/span&gt;
git clone https://github.com/rock288/go-mongo-boilerplate my-service
&lt;span class="nb"&gt;cd &lt;/span&gt;my-service

&lt;span class="c"&gt;# 2. Rename the module path (everywhere it appears)&lt;/span&gt;
&lt;span class="nv"&gt;NEW&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;github.com/&amp;lt;your-org&amp;gt;/&amp;lt;your-repo&amp;gt;
&lt;span class="nv"&gt;OLD&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;github.com/rock288/go-mongo-boilerplate
go mod edit &lt;span class="nt"&gt;-module&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nv"&gt;$NEW&lt;/span&gt;
&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rl&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$OLD&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.go'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.yml'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.yaml'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'Makefile'&lt;/span&gt; &lt;span class="nt"&gt;--include&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;'*.md'&lt;/span&gt; &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  | xargs perl &lt;span class="nt"&gt;-pi&lt;/span&gt; &lt;span class="nt"&gt;-e&lt;/span&gt; &lt;span class="s2"&gt;"s|&lt;/span&gt;&lt;span class="nv"&gt;$OLD&lt;/span&gt;&lt;span class="s2"&gt;|&lt;/span&gt;&lt;span class="nv"&gt;$NEW&lt;/span&gt;&lt;span class="s2"&gt;|g"&lt;/span&gt;
make wire &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; make mocks

&lt;span class="c"&gt;# 3. Start your first feature&lt;/span&gt;
make scaffold &lt;span class="nv"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;Order
&lt;span class="c"&gt;# → internal/order/ ready; edit model.go, dto.go, wire it up&lt;/span&gt;

&lt;span class="c"&gt;# 4. Run&lt;/span&gt;
docker-compose up &lt;span class="nt"&gt;-d&lt;/span&gt;
make migrate-up
make run                 &lt;span class="c"&gt;# http://localhost:8002&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;make ci&lt;/code&gt; should be green. If it isn't, that's a bug — open an issue.&lt;/p&gt;




&lt;h2&gt;
  
  
  What I'd like feedback on
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Is &lt;code&gt;internal/middleware/&lt;/code&gt; cleanly separated, or should it move under &lt;code&gt;internal/platform/httpserver/middleware/&lt;/code&gt;?&lt;/li&gt;
&lt;li&gt;The SQS app-level retry queue costs an extra &lt;code&gt;SendMessage&lt;/code&gt; per failure compared to native SQS redrive. I chose it for Kafka-symmetry. Worth the cost?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;MessageDelaySeconds&lt;/code&gt; 900s cap forces clamping. For backoffs &amp;gt;15min I think the right answer is "use Step Functions" — but it's not built in. Should it be?&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;CLAUDE.md&lt;/code&gt; + &lt;code&gt;AGENTS.md&lt;/code&gt; — pro or con for a public template? I've heard both.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Open an issue if you have opinions or — better — a PR.&lt;/p&gt;




&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Repo&lt;/strong&gt;: &lt;a href="https://github.com/rock288/go-mongo-boilerplate" rel="noopener noreferrer"&gt;https://github.com/rock288/go-mongo-boilerplate&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Use this template&lt;/strong&gt; button: top-right on GitHub&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Architecture doc&lt;/strong&gt;: &lt;a href="https://github.com/rock288/go-mongo-boilerplate/blob/main/docs/architecture.md" rel="noopener noreferrer"&gt;docs/architecture.md&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Why no auth?&lt;/strong&gt; &lt;a href="https://github.com/rock288/go-mongo-boilerplate/blob/main/CLAUDE.md" rel="noopener noreferrer"&gt;CLAUDE.md&lt;/a&gt; — last section&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If this saved you an afternoon, leave a star or share with a teammate who's about to start their seventh Go service.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;If you spot a bug, an outdated dep, or a convention I should fight you about, open an issue. The repo gets better when people argue.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>opensource</category>
      <category>microservices</category>
      <category>observability</category>
    </item>
  </channel>
</rss>
