<?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: Dávid Pörös</title>
    <description>The latest articles on DEV Community by Dávid Pörös (@davidporos92).</description>
    <link>https://dev.to/davidporos92</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%2F4169172%2F0deac0b9-578b-4393-ad00-00d9102fcd6f.png</url>
      <title>DEV Community: Dávid Pörös</title>
      <link>https://dev.to/davidporos92</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/davidporos92"/>
    <language>en</language>
    <item>
      <title>Building MarginCMS, part 2: A Go server generated from the spec</title>
      <dc:creator>Dávid Pörös</dc:creator>
      <pubDate>Wed, 07 Oct 2026 14:41:28 +0000</pubDate>
      <link>https://dev.to/davidporos92/building-margincms-part-2-a-go-server-generated-from-the-spec-5875</link>
      <guid>https://dev.to/davidporos92/building-margincms-part-2-a-go-server-generated-from-the-spec-5875</guid>
      <description>&lt;p&gt;In &lt;a href="https://davidporos92.github.io/posts/building-margincms-part-1-contract-first-code-later/" rel="noopener noreferrer"&gt;part 1&lt;/a&gt; I wrote the OpenAPI contract for MarginCMS and planned the backend. In this part the Go service comes to life: a server generated from the spec, config from environment variables, stub handlers that fail properly, and a Docker Compose setup that runs the API, the docs and a mock server with one command.&lt;/p&gt;

&lt;p&gt;It also includes a CORS bug where my server logged every request and the browser still showed nothing. That one gets its own section.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why generate the server at all?
&lt;/h2&gt;

&lt;p&gt;The contract is the only thing my wife's frontend and my backend share. If the Go handlers are written by hand, they'll drift from the spec sooner or later: a renamed field, a missing status code, a query parameter parsed as the wrong type. With a generated server, the handlers implement an interface that comes straight from the spec. When the spec changes, I regenerate, and the compiler tells me exactly what's out of date.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Choose the server and generate it from the spec
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Commit:&lt;/strong&gt; &lt;a href="https://github.com/davidporos92/margin-cms/commit/4ca2fb4" rel="noopener noreferrer"&gt;api: generate Go server and types from the OpenAPI spec&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  chi
&lt;/h3&gt;

&lt;p&gt;I picked &lt;a href="https://github.com/go-chi/chi" rel="noopener noreferrer"&gt;chi&lt;/a&gt; as the router:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;I've used it before, so it's one less new thing.&lt;/li&gt;
&lt;li&gt;It's a thin layer on top of &lt;code&gt;net/http&lt;/code&gt;. Handlers are plain &lt;code&gt;http.Handler&lt;/code&gt;s, and anything from the standard library works with it.&lt;/li&gt;
&lt;li&gt;It has a good community and plenty of middleware and packages, including the CORS package that shows up later in this post.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  oapi-codegen
&lt;/h3&gt;

&lt;p&gt;&lt;a href="https://github.com/oapi-codegen/oapi-codegen" rel="noopener noreferrer"&gt;oapi-codegen&lt;/a&gt; turns the bundled spec into Go code. I generate four files, each from its own small config:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Config&lt;/th&gt;
&lt;th&gt;Generates&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;models.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Request and response types&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;server.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A chi server and a &lt;em&gt;strict&lt;/em&gt; server interface&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;client.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;A typed Go client, useful for tests later&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;server_urls.yaml&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Constants for the server URLs in the spec&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The strict server is the important part. Instead of &lt;code&gt;func(w http.ResponseWriter, r *http.Request)&lt;/code&gt;, each operation becomes a method with typed input and typed output:&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;s&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;GetHealth&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;request&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetHealthRequestObject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;GetHealthResponseObject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The generated code parses the path, query, headers and body, calls my method, and writes the response. My code never touches &lt;code&gt;http.Request&lt;/code&gt;. The return type is an interface that the generated per-status response types implement, so returning a response the spec doesn't define takes deliberate effort instead of happening by accident.&lt;/p&gt;

&lt;h3&gt;
  
  
  Empty string or no field at all?
&lt;/h3&gt;

&lt;p&gt;A few years ago I worked with code generated from proto3 messages with plain &lt;code&gt;string&lt;/code&gt; fields. Without the &lt;code&gt;optional&lt;/code&gt; keyword (generally available only since protobuf 3.15) or wrapper types like &lt;code&gt;StringValue&lt;/code&gt;, proto3 can't tell the difference between a client sending &lt;code&gt;"title": ""&lt;/code&gt; and not sending &lt;code&gt;title&lt;/code&gt; at all. Both arrived as an empty string. For a &lt;code&gt;PATCH&lt;/code&gt; endpoint that's a real problem: "clear the excerpt" and "don't touch the excerpt" look the same.&lt;/p&gt;

&lt;p&gt;oapi-codegen handles this the Go way. Optional fields become pointers:&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;PostUpdate&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;Body&lt;/span&gt;    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;     &lt;span class="s"&gt;`json:"body,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Excerpt&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;     &lt;span class="s"&gt;`json:"excerpt,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Slug&lt;/span&gt;    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;     &lt;span class="s"&gt;`json:"slug,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Status&lt;/span&gt;  &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;PostStatus&lt;/span&gt; &lt;span class="s"&gt;`json:"status,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Tags&lt;/span&gt;    &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;   &lt;span class="s"&gt;`json:"tags,omitempty"`&lt;/span&gt;
    &lt;span class="n"&gt;Title&lt;/span&gt;   &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt;     &lt;span class="s"&gt;`json:"title,omitempty"`&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;nil&lt;/code&gt; means "not sent", and a pointer to &lt;code&gt;""&lt;/code&gt; means "set it to empty" (for &lt;code&gt;excerpt&lt;/code&gt; and &lt;code&gt;body&lt;/code&gt;; &lt;code&gt;title&lt;/code&gt; has &lt;code&gt;minLength: 1&lt;/code&gt;). That's exactly what the &lt;code&gt;PATCH&lt;/code&gt; semantics need.&lt;/p&gt;

&lt;p&gt;One caveat: an explicit &lt;code&gt;"excerpt": null&lt;/code&gt; also decodes to &lt;code&gt;nil&lt;/code&gt;, so "not sent" and "sent as null" look the same. That's fine here because none of these fields are nullable. If I ever need the difference, oapi-codegen's &lt;code&gt;nullable-type&lt;/code&gt; output option generates &lt;code&gt;nullable.Nullable[T]&lt;/code&gt; for it.&lt;/p&gt;

&lt;h3&gt;
  
  
  Tools pinned with &lt;code&gt;go tool&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;My first version installed the generator with &lt;code&gt;go install ...@latest&lt;/code&gt;. That works on my machine today, but it means anyone cloning the repo next year gets a different generator than the one that produced the committed code.&lt;/p&gt;

&lt;p&gt;Since Go 1.24, &lt;code&gt;go.mod&lt;/code&gt; can track tools the same way it tracks dependencies:&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;tool&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;github&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;air&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;verse&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;air&lt;/span&gt;
    &lt;span class="n"&gt;github&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;com&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;oapi&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;codegen&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;oapi&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;codegen&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;v2&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;cmd&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;oapi&lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="n"&gt;codegen&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Now &lt;code&gt;go tool oapi-codegen&lt;/code&gt; always runs the exact version in &lt;code&gt;go.mod&lt;/code&gt;, and the root &lt;code&gt;Makefile&lt;/code&gt; uses it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight make"&gt;&lt;code&gt;&lt;span class="nl"&gt;openapi-generate-go&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
    &lt;span class="nb"&gt;cd &lt;/span&gt;api &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; go tool oapi-codegen &lt;span class="nt"&gt;--config&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;../openapi/oapi-codegen/server.yaml ../openapi/dist/openapi.bundled.yaml
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The trade-off is that the tools' dependencies show up in &lt;code&gt;go.mod&lt;/code&gt; as indirect requirements. They don't end up in the API binary, but the file gets longer. I'm fine with that in exchange for one source of truth for versions.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Config, stubs and a health check
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Commit:&lt;/strong&gt; &lt;a href="https://github.com/davidporos92/margin-cms/commit/ae3c5d7" rel="noopener noreferrer"&gt;api: add server skeleton with config, stub handlers and health check&lt;/a&gt;&lt;/p&gt;

&lt;h3&gt;
  
  
  Config from the environment
&lt;/h3&gt;

&lt;p&gt;Config comes from environment variables, mapped onto structs with &lt;a href="https://github.com/sethvargo/go-envconfig" rel="noopener noreferrer"&gt;go-envconfig&lt;/a&gt;. Each package owns its own config struct, and the top-level config just composes them:&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/server/config.go&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Config&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;Addr&lt;/span&gt;              &lt;span class="kt"&gt;string&lt;/span&gt;        &lt;span class="s"&gt;`env:"SERVER_ADDR, default=:8080"`&lt;/span&gt;
    &lt;span class="n"&gt;ShutdownTimeout&lt;/span&gt;   &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="s"&gt;`env:"SERVER_SHUTDOWN_TIMEOUT, default=30s"`&lt;/span&gt;
    &lt;span class="n"&gt;ReadHeaderTimeout&lt;/span&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Duration&lt;/span&gt; &lt;span class="s"&gt;`env:"SERVER_READ_HEADER_TIMEOUT, default=5s"`&lt;/span&gt;
    &lt;span class="c"&gt;// ...&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c"&gt;// internal/config/config.go&lt;/span&gt;
&lt;span class="k"&gt;type&lt;/span&gt; &lt;span class="n"&gt;Config&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;Server&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;server&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;When the database and auth packages arrive, they'll add their own &lt;code&gt;Config&lt;/code&gt; next to their code instead of one giant struct growing in a single file.&lt;/p&gt;

&lt;p&gt;There's also a test that loads the config with no &lt;code&gt;SERVER_*&lt;/code&gt; variables set and compares it with the expected defaults. It sounds trivial, but it's already caught me twice: once when I added the CORS and timeout fields, and once when I lowered a timeout default. Defaults are part of the behaviour, so they deserve a test. It also makes me look at &lt;code&gt;.env.example&lt;/code&gt; every time a default changes, so the code and the docs don't drift apart.&lt;/p&gt;

&lt;h3&gt;
  
  
  &lt;code&gt;main.go&lt;/code&gt;
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;main.go&lt;/code&gt; stays small: load the config, build the generated handler, wrap it, and run an &lt;code&gt;http.Server&lt;/code&gt; with timeouts and graceful shutdown:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;stop&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;signal&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NotifyContext&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;Background&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SIGINT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;syscall&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SIGTERM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;stop&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;New&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;serverInterface&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewStrictHandlerWithOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewServerMiddlewares&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewServerOptions&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
&lt;span class="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;HandlerWithOptions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;serverInterface&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ChiServerOptions&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BaseURL&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s"&gt;"/api/v1"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;On &lt;code&gt;SIGINT&lt;/code&gt; or &lt;code&gt;SIGTERM&lt;/code&gt; the server stops accepting new connections and gives in-flight requests up to &lt;code&gt;SERVER_SHUTDOWN_TIMEOUT&lt;/code&gt; to finish.&lt;/p&gt;

&lt;h3&gt;
  
  
  501 for everything that isn't built yet
&lt;/h3&gt;

&lt;p&gt;The generated interface has 17 methods, and I've implemented one. The stubs for the rest started out as &lt;code&gt;panic("implement me")&lt;/code&gt;. Go's HTTP server recovers from the panic, but the client gets a dropped connection and my log gets a stack trace. Not great when the docs page has a "Try it" button.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;501 Not Implemented&lt;/code&gt; is the honest answer. The catch is that the strict server only lets a method return the responses the spec defines, and the spec has no 501. It doesn't need one, though: any &lt;code&gt;error&lt;/code&gt; a method returns goes to a configurable &lt;code&gt;ResponseErrorHandlerFunc&lt;/code&gt;. So every stub returns a sentinel error:&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;var&lt;/span&gt; &lt;span class="n"&gt;ErrNotImplemented&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;New&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"not implemented"&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;s&lt;/span&gt; &lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;ListPosts&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;request&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ListPostsRequestObject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ListPostsResponseObject&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;return&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;ErrNotImplemented&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and the error handler maps it to a status code and writes a &lt;code&gt;problem+json&lt;/code&gt; body using the &lt;code&gt;Problem&lt;/code&gt; type generated from the spec:&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;responseErrorHandler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ResponseWriter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Request&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&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;status&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusInternalServerError&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Is&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ErrNotImplemented&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;http&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;StatusNotImplemented&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Printf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"%s %s %s"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;URL&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="n"&gt;writeProblem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A nice side effect: real errors returned from handlers now also come back as a clean &lt;code&gt;500&lt;/code&gt; in the same format, and the internal error message is logged instead of being sent to the client. Request-decoding errors still get oapi-codegen's default plain-text 400 for now.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;curl http://localhost:8080/api/v1/posts
&lt;span class="o"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"status"&lt;/span&gt;:501,&lt;span class="s2"&gt;"title"&lt;/span&gt;:&lt;span class="s2"&gt;"Not Implemented"&lt;/span&gt;,&lt;span class="s2"&gt;"type"&lt;/span&gt;:&lt;span class="s2"&gt;"about:blank"&lt;/span&gt;&lt;span class="o"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Health check
&lt;/h3&gt;

&lt;p&gt;&lt;code&gt;GET /api/v1/healthz&lt;/code&gt; returns &lt;code&gt;{"status":"OK"}&lt;/code&gt;. That's a stub for now. Once there's a database, it'll check that the app's dependencies are reachable and healthy, so Docker and any future deployment can tell a running process from a working one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: One command for the whole dev environment
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Commit:&lt;/strong&gt; &lt;a href="https://github.com/davidporos92/margin-cms/commit/a851c73" rel="noopener noreferrer"&gt;tooling: add Docker Compose dev environment&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;docker compose up&lt;/code&gt; starts three services:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Service&lt;/th&gt;
&lt;th&gt;Port&lt;/th&gt;
&lt;th&gt;What it is&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;api&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8080&lt;/td&gt;
&lt;td&gt;The Go API, rebuilt on every save by &lt;a href="https://github.com/air-verse/air" rel="noopener noreferrer"&gt;Air&lt;/a&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apidocs&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8081&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;redocly preview&lt;/code&gt;: the API docs with a "Try it" console&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;apimock&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;8082&lt;/td&gt;
&lt;td&gt;Prism, serving mock responses from the spec&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The &lt;code&gt;api&lt;/code&gt; container mounts the source code and runs &lt;code&gt;go tool air&lt;/code&gt;, so Air is pinned in &lt;code&gt;go.mod&lt;/code&gt; like the generator. Two named volumes keep the Go module cache and the build cache (which holds the compiled Air binary) between container restarts. Without them, every recreated container downloaded all modules and compiled Air from scratch before serving a single request.&lt;/p&gt;

&lt;h3&gt;
  
  
  Do I still need Prism?
&lt;/h3&gt;

&lt;p&gt;While setting this up, I noticed that the Redocly preview also has a built-in mock server behind its "Try it" console. So is Prism redundant?&lt;/p&gt;

&lt;p&gt;Not for us. The Redocly mock is great for trying endpoints from the docs page. Prism is a standalone server on a fixed port that the React app can use as its backend while my endpoints are still returning 501. It also validates requests against the spec and supports the &lt;code&gt;Prefer&lt;/code&gt; header, so the frontend can ask for a specific response, like &lt;code&gt;Prefer: code=412&lt;/code&gt; for the edit-conflict case. That's what the frontend needs, so Prism stays.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: The request that arrived and never came back
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Commit:&lt;/strong&gt; &lt;a href="https://github.com/davidporos92/margin-cms/commit/88a35c1" rel="noopener noreferrer"&gt;api: add CORS middleware&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;With everything running, I opened the docs on &lt;code&gt;localhost:8081&lt;/code&gt;, pointed the "Try it" console at my real API on &lt;code&gt;localhost:8080&lt;/code&gt;, and called the health check. The API logged the request. The docs page showed no response at all.&lt;/p&gt;

&lt;h3&gt;
  
  
  Part one: CORS
&lt;/h3&gt;

&lt;p&gt;The server did its job. The browser threw the response away.&lt;/p&gt;

&lt;p&gt;A different port is a different origin, so a page on &lt;code&gt;:8081&lt;/code&gt; calling &lt;code&gt;:8080&lt;/code&gt; makes a cross-origin request. A plain &lt;code&gt;GET&lt;/code&gt; without custom headers is a "simple" request, so the browser sends it straight away without a preflight. My handler runs and returns 200. But the response has no &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt; header, so the browser refuses to let the page read it. The devtools console says so, if you think to look there.&lt;/p&gt;

&lt;p&gt;The fix is the &lt;a href="https://github.com/go-chi/cors" rel="noopener noreferrer"&gt;go-chi/cors&lt;/a&gt; middleware. One detail matters: it has to run before chi's routing, so I wrap the whole handler at the &lt;code&gt;net/http&lt;/code&gt; level instead of adding it to the strict server's middleware list. A preflight &lt;code&gt;OPTIONS&lt;/code&gt; request doesn't match any generated route (chi answers 405 or 404 itself), so per-operation middleware never sees 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="n"&gt;handler&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Handler&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Options&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;AllowedOrigins&lt;/span&gt;&lt;span class="o"&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;Server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllowedOrigins&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;AllowedHeaders&lt;/span&gt;&lt;span class="o"&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;Server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllowedHeaders&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;AllowedMethods&lt;/span&gt;&lt;span class="o"&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;Server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllowedMethods&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;AllowCredentials&lt;/span&gt;&lt;span class="o"&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;Server&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AllowCredentials&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;})(&lt;/span&gt;&lt;span class="n"&gt;handler&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Part two: &lt;code&gt;*&lt;/code&gt; means "all methods" in the spec, but not in go-chi/cors
&lt;/h3&gt;

&lt;p&gt;I added CORS, set everything to &lt;code&gt;*&lt;/code&gt; in my local env to get going, and still got nothing.&lt;/p&gt;

&lt;p&gt;Calling the API with &lt;code&gt;curl&lt;/code&gt; and an &lt;code&gt;Origin&lt;/code&gt; header showed the middleware was running, since the response had &lt;code&gt;Vary: Origin&lt;/code&gt;, but there was still no &lt;code&gt;Access-Control-Allow-Origin&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;That surprised me, because &lt;code&gt;*&lt;/code&gt; is a valid value for the &lt;a href="https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Access-Control-Allow-Methods" rel="noopener noreferrer"&gt;&lt;code&gt;Access-Control-Allow-Methods&lt;/code&gt;&lt;/a&gt; header: for requests without credentials, it means "all methods". The protocol was fine; the library was the problem. Reading the go-chi/cors source explained it. In its options, &lt;code&gt;*&lt;/code&gt; is a wildcard for &lt;code&gt;AllowedOrigins&lt;/code&gt; and &lt;code&gt;AllowedHeaders&lt;/code&gt;, but not for &lt;code&gt;AllowedMethods&lt;/code&gt;. There, &lt;code&gt;*&lt;/code&gt; is compared literally against the request method, as if it were a method called &lt;code&gt;*&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;On top of that, go-chi/cors checks the method on the actual request too, not only on the preflight, which the CORS spec doesn't ask for. My &lt;code&gt;GET&lt;/code&gt; was a simple request with no preflight, and &lt;code&gt;GET&lt;/code&gt; wasn't in my list, so it got no CORS headers at all. A preflighted request wouldn't have fared better: the preflight checks the requested method against the same list, and on a mismatch it still answers &lt;code&gt;200 OK&lt;/code&gt;, just without any CORS headers.&lt;/p&gt;

&lt;p&gt;Even a library that passed &lt;code&gt;*&lt;/code&gt; through wouldn't have saved this config, because I also had &lt;code&gt;AllowCredentials: true&lt;/code&gt;. For requests with credentials (cookies, TLS client certificates or HTTP authentication), browsers treat &lt;code&gt;*&lt;/code&gt; in &lt;code&gt;Access-Control-Allow-Methods&lt;/code&gt; and &lt;code&gt;Access-Control-Allow-Headers&lt;/code&gt; as a literal name. That setting had a problem of its own, which is part three.&lt;/p&gt;

&lt;p&gt;Listing the methods explicitly fixed it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;SERVER_ALLOWED_METHODS&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s2"&gt;"GET,POST,PUT,PATCH,DELETE,OPTIONS"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  Part three: don't ship the permissive version
&lt;/h3&gt;

&lt;p&gt;While I was there, I noticed a config that only worked by accident. &lt;code&gt;AllowedOrigins: *&lt;/code&gt; with &lt;code&gt;AllowCredentials: true&lt;/code&gt; makes go-chi/cors send &lt;code&gt;Access-Control-Allow-Origin: *&lt;/code&gt; next to &lt;code&gt;Access-Control-Allow-Credentials: true&lt;/code&gt;, a combination browsers reject for credentialed requests. MarginCMS uses bearer tokens and no cookies, so it doesn't need credentials at all, and there's no reason to let every origin in either. The defaults are now strict:&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;AllowedOrigins&lt;/span&gt;   &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`env:"SERVER_ALLOWED_ORIGINS, default=http://localhost:8081"`&lt;/span&gt;
&lt;span class="n"&gt;AllowedMethods&lt;/span&gt;   &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`env:"SERVER_ALLOWED_METHODS, default=GET,POST,PUT,PATCH,DELETE,OPTIONS"`&lt;/span&gt;
&lt;span class="n"&gt;AllowedHeaders&lt;/span&gt;   &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="s"&gt;`env:"SERVER_ALLOWED_HEADERS, default=Authorization,Content-Type"`&lt;/span&gt;
&lt;span class="n"&gt;AllowCredentials&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;     &lt;span class="s"&gt;`env:"SERVER_ALLOW_CREDENTIALS, default=false"`&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If it works locally with &lt;code&gt;*&lt;/code&gt;, that's a sign to tighten it, not a sign that you're done.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell myself before starting
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;When the server says 200 and the client sees nothing, the browser is the one dropping the response. Start debugging there.&lt;/li&gt;
&lt;li&gt;Read the source of small middleware packages. go-chi/cors is a few hundred lines, and five minutes of reading beat an hour of guessing.&lt;/li&gt;
&lt;li&gt;Pin your code generators. Generated code is only reproducible if the generator version is.&lt;/li&gt;
&lt;li&gt;Test your defaults. It's the cheapest test you'll write and it keeps config and docs honest.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Add a linter.&lt;/strong&gt; &lt;a href="https://golangci-lint.run/" rel="noopener noreferrer"&gt;golangci-lint&lt;/a&gt; with a config that's strict from day one, while there's still almost no code to fix.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Start on auth with mock data.&lt;/strong&gt; Login, token refresh, logout and &lt;code&gt;GET /me&lt;/code&gt;, backed by an in-memory user for now, so the auth flow and its tests exist before the database does.&lt;/li&gt;
&lt;/ol&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://davidporos92.github.io/posts/building-margincms-part-2-a-go-server-generated-from-the-spec/" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;, where the comments live. The code for this post is &lt;a href="https://github.com/davidporos92/margin-cms/tree/part-2" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>openapi</category>
      <category>docker</category>
      <category>margincms</category>
    </item>
    <item>
      <title>Building MarginCMS, part 1: Contract first, code later</title>
      <dc:creator>Dávid Pörös</dc:creator>
      <pubDate>Wed, 07 Oct 2026 14:41:20 +0000</pubDate>
      <link>https://dev.to/davidporos92/building-margincms-part-1-contract-first-code-later-141g</link>
      <guid>https://dev.to/davidporos92/building-margincms-part-1-contract-first-code-later-141g</guid>
      <description>&lt;p&gt;I'm building a small CMS in Go. It's called MarginCMS, it has a React admin on top, and it won't replace anything I already use. This series is the build log: what I decided, what I wrote, and what tripped me up along the way.&lt;/p&gt;

&lt;p&gt;This first part covers why I'm doing it, and the work that happened before writing any Go code: the API contract, the tooling around it, and the plan for the backend.&lt;/p&gt;

&lt;p&gt;The code is public at &lt;a href="https://github.com/davidporos92/margin-cms" rel="noopener noreferrer"&gt;github.com/davidporos92/margin-cms&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why build a CMS in 2026?
&lt;/h2&gt;

&lt;p&gt;Nobody needs another CMS, including me. I'm building one anyway, for four reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It's fun.&lt;/strong&gt; Writing a CMS has been on my someday list for years, and a pet project with no deadline and no users is the best place to finally do it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Deliberate practice.&lt;/strong&gt; I use AI tools every day, and they're good. But I noticed I reach for them before I've thought a problem through. I want one project where I make every decision and write the code myself, with AI as a reviewer and a rubber duck rather than the author.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;It starts easy and gets hard.&lt;/strong&gt; A CMS starts as CRUD over a &lt;code&gt;posts&lt;/code&gt; table, which is a nice way to ease back in. Then the real problems show up: authentication and token refresh, revision history, two people editing the same post at the same time, search, pagination. Each of these is small enough to finish and deep enough to learn something from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;There's a lot of room to grow.&lt;/strong&gt; Once the core works, there's room for tools, plugins and packages around it: an export pipeline to my existing blog, a CLI, maybe a client library. None of that is planned yet, but the option is there.&lt;/p&gt;

&lt;p&gt;My wife is building the React frontend, so this is a two-person project with a clean split: I own the Go API, she owns the web app and the design system. The only thing we share is the API contract (and a &lt;a href="https://www.instagram.com/pck.kalandjai" rel="noopener noreferrer"&gt;small little doggo&lt;/a&gt;), and that shapes almost every decision below.&lt;/p&gt;

&lt;h2&gt;
  
  
  The scope, briefly
&lt;/h2&gt;

&lt;p&gt;To keep it finishable, v1 is deliberately boring:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;one admin user, one content type (posts), no media uploads, no plugin system&lt;/li&gt;
&lt;li&gt;posts are markdown with a title, slug, excerpt, tags and a status (&lt;code&gt;draft&lt;/code&gt;, &lt;code&gt;published&lt;/code&gt;, &lt;code&gt;archived&lt;/code&gt;)&lt;/li&gt;
&lt;li&gt;every content change creates a revision, and an activity log records who did what&lt;/li&gt;
&lt;li&gt;one Go binary with small internal packages (&lt;code&gt;auth&lt;/code&gt;, &lt;code&gt;posts&lt;/code&gt;, &lt;code&gt;revisions&lt;/code&gt;, &lt;code&gt;activity&lt;/code&gt;, &lt;code&gt;stats&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;"Modular" here means compile-time packages with small interfaces, not a dynamic content-type builder.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: Write the OpenAPI contract first
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Commit:&lt;/strong&gt; &lt;a href="https://github.com/davidporos92/margin-cms/commit/065a09c" rel="noopener noreferrer"&gt;Add OpenAPI contract and spec tooling&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Before writing a single handler, I wrote the API as an OpenAPI 3 spec. That had two goals.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't block the frontend.&lt;/strong&gt; If the frontend has to wait for my endpoints, it'll wait a long time. With a spec in place, the frontend can generate its TypeScript types and develop against a mock server from day one, and switch the base URL once the real endpoints land.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Get a clear picture of what I'm building.&lt;/strong&gt; Writing the contract forced decisions I would otherwise have made halfway through a handler:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Auth:&lt;/strong&gt; a short-lived access token in &lt;code&gt;Authorization: Bearer&lt;/code&gt;, plus a single-use refresh token. No cookies, so classic CSRF doesn't apply; XSS is the thing to guard against instead.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Errors:&lt;/strong&gt; every error is &lt;code&gt;application/problem+json&lt;/code&gt; (RFC 9457), with field-level details for form validation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimistic concurrency:&lt;/strong&gt; posts carry an &lt;code&gt;ETag&lt;/code&gt;, and updates, deletes and revision restores require &lt;code&gt;If-Match&lt;/code&gt;. A stale write gets &lt;code&gt;412 Precondition Failed&lt;/code&gt; with the current version of the post in the response, so the UI can show a proper conflict banner instead of silently overwriting someone's work.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pagination:&lt;/strong&gt; cursor-based, not offset-based.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result is 17 operations across auth, posts (including bulk actions and markdown export), revisions, tags, activity, stats and a health check.&lt;/p&gt;

&lt;p&gt;I didn't want one 2,000-line YAML file, so the spec is split into small files: one per path and one per component (schemas, parameters, request bodies, responses), all referenced from a root &lt;code&gt;openapi.yaml&lt;/code&gt;. The convention is simple: file name equals component name. &lt;code&gt;components/schemas/Post.yaml&lt;/code&gt; defines &lt;code&gt;Post&lt;/code&gt;. A bundler merges everything into a single file for the tools that need one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: Set up tooling around the spec
&lt;/h2&gt;

&lt;p&gt;A spec is only useful if the tooling around it is easy to use, so the same commit adds npm scripts and &lt;code&gt;make&lt;/code&gt; targets for:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Need&lt;/th&gt;
&lt;th&gt;Tool&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Lint the spec&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://redocly.com/docs/cli/" rel="noopener noreferrer"&gt;Redocly CLI&lt;/a&gt; (&lt;code&gt;redocly lint&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Bundle the split files into one&lt;/td&gt;
&lt;td&gt;Redocly CLI (&lt;code&gt;redocly bundle&lt;/code&gt;)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Browse the docs&lt;/td&gt;
&lt;td&gt;Redocly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Mock server&lt;/td&gt;
&lt;td&gt;&lt;a href="https://stoplight.io/open-source/prism" rel="noopener noreferrer"&gt;Prism&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;TypeScript types&lt;/td&gt;
&lt;td&gt;&lt;a href="https://openapi-ts.dev/" rel="noopener noreferrer"&gt;openapi-typescript&lt;/a&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Go types and server&lt;/td&gt;
&lt;td&gt;
&lt;a href="https://github.com/oapi-codegen/oapi-codegen" rel="noopener noreferrer"&gt;oapi-codegen&lt;/a&gt; (wired up in part 2)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Prism deserves a special mention. Besides returning example responses, it supports the &lt;code&gt;Prefer&lt;/code&gt; header. The frontend can send &lt;code&gt;Prefer: code=412&lt;/code&gt; and get a 412 back, which makes it possible to build the conflict handling long before the backend can produce a real conflict.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: Plan the Go backend
&lt;/h2&gt;

&lt;p&gt;No commit for this one. It's the thinking that happened before the code.&lt;/p&gt;

&lt;p&gt;I picked tools in three groups.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The HTTP server.&lt;/strong&gt; I went with &lt;a href="https://github.com/go-chi/chi" rel="noopener noreferrer"&gt;chi&lt;/a&gt;. I've used it before, it stays close to &lt;code&gt;net/http&lt;/code&gt;, and oapi-codegen can generate a chi server directly from the spec. More on that in part 2.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Data access.&lt;/strong&gt; The classic Go debate is ORM versus plain SQL. Since part of the point is to learn new things while I ease back into Go, I chose tools I haven't used in anger yet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;PostgreSQL&lt;/strong&gt; with &lt;a href="https://github.com/jackc/pgx" rel="noopener noreferrer"&gt;pgx&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://entgo.io/" rel="noopener noreferrer"&gt;Ent&lt;/a&gt;&lt;/strong&gt; for the schema and data access, with the schema written as Go code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://atlasgo.io/" rel="noopener noreferrer"&gt;Atlas&lt;/a&gt;&lt;/strong&gt; to generate versioned SQL migrations from the Ent schema. The API never changes the database schema on its own.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://golang.testcontainers.org/" rel="noopener noreferrer"&gt;testcontainers-go&lt;/a&gt;&lt;/strong&gt; for integration tests against a real Postgres, using the same migration files that ship&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Code generation from OpenAPI.&lt;/strong&gt; Handlers implement an interface generated from the spec, so the code can't drift from the contract without the compiler noticing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4: Plan the local dev setup
&lt;/h2&gt;

&lt;p&gt;Also mostly planning at this stage. The goal was a dev loop where everything runs with one command:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Go hot reload&lt;/strong&gt; with &lt;a href="https://github.com/air-verse/air" rel="noopener noreferrer"&gt;Air&lt;/a&gt;, so saving a file rebuilds and restarts the API&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Prism&lt;/strong&gt; as the mock backend for the web app, for the &lt;code&gt;Prefer&lt;/code&gt; header support mentioned above&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Docker Compose&lt;/strong&gt; so we both get the same environment, including Postgres later&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;In &lt;a href="https://davidporos92.github.io/posts/building-margincms-part-2-a-go-server-generated-from-the-spec/" rel="noopener noreferrer"&gt;part 2&lt;/a&gt; I set up the Go service and the local environment: generating the server from the spec, config handling, stub handlers, the Docker Compose setup, and a CORS bug that turned out to be a library quirk, not the protocol.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://davidporos92.github.io/posts/building-margincms-part-1-contract-first-code-later/" rel="noopener noreferrer"&gt;my blog&lt;/a&gt;, where the comments live. The code for this post is &lt;a href="https://github.com/davidporos92/margin-cms/tree/part-1" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>go</category>
      <category>openapi</category>
      <category>margincms</category>
    </item>
  </channel>
</rss>
