When I start a new service, I do not write the HTTP handlers by hand. I describe the API in an OpenAPI file first, and the handlers are generated from it. The compiler then will not let the spec and the code drift apart: you change the spec, run one command, and the build stays broken until the handlers match. I would not build a JSON API any other way now, and the reason is the mess it replaced.
The way I used to do it
For years I did this the other way round, and the pain is the whole argument for switching. You write the handlers first, then go back and decorate each one with doc comments (@Summary, @Success 200 {object} PaddleResponse, that sort of thing), and a tool reads the comments and generates the spec out of them.
It never held together for me. I would change a handler and forget to update the comment sitting above it, so the docs quietly went out of date and nobody noticed until a consumer did. When I did remember, the generated output had problems of its own: a schema that came out malformed, or a Swagger page that would not render, and there went an afternoon to comment syntax. And under all of it I was still hand-writing the handlers, which are the most boring code in the service. A handler takes a request, passes it to something the service can actually do, and maps the result back. That is the whole job. Keeping a second, comment-shaped copy of that boring code in sync by hand is the worst of both worlds.
Doing the spec first turns it round. The spec is the thing you write, the boring handlers are generated for you, and there is no comment left to forget.
The spec goes first, always
The rule is one line: every endpoint exists in openapi.yaml before a handler is written. The spec is the source of truth, not a document someone updates afterwards and quietly lies in.
From the spec, oapi-codegen generates two things: the Go types for every request and response body, and a ServerInterface with one method per operation. You implement that interface. The compiler enforces the contract, so a missing method fails the build and a handler whose types do not match the spec fails the build.
What a change actually looks like
Say you are adding the public homepage. First it goes in the spec:
paths:
/:
get:
operationId: home
tags: [html]
summary: Homepage
security: []
responses:
'200':
description: Homepage
content:
text/html:
schema: { type: string }
'500':
$ref: '#/components/responses/InternalError'Then one command:
make gen-api # go generate ./transports/restThat regenerates types.gen.go and server.gen.go, and the ServerInterface now has a method it did not have before:
type ServerInterface interface {
// (GET /)
Home(ctx echo.Context) error
// ... one per operation
}The build is red until you implement it. So you do, and the handler is the thin thing from the layout post: parse, call the service, render.
func (h *HomeHandler) Home(c echo.Context) error {
view, err := h.home.Homepage(c.Request().Context())
if err != nil {
return err
}
return renderHTML(c, home.Page(home.HomeProps{View: view}))
}RegisterHandlers wires every generated route to your implementation in one call, so you never write e.GET("/", ...) by hand, and you never end up with a route the spec does not know about.
Two details that make it stick
The operationId becomes the method name. operationId: home generates Home, so name your operations the way you want your Go methods named. Sloppy operationIds give you sloppy handlers.
HTML pages go in the spec too. The app these examples come from is server-rendered with templ, and every page and htmx fragment is still declared in openapi.yaml with a text/html response. They return HTML, but they are HTTP endpoints, and I want the whole surface in one document rather than the JSON half in a spec and the HTML half discoverable only by reading route registrations.
Why the generated code is committed
types.gen.go and server.gen.go are checked into git, and a CI job runs make gen-api and fails if the result differs from what is committed. That sounds redundant, and it is the thing that keeps the spec honest: you cannot change the spec, skip the codegen, and ship handlers that quietly disagree with the document. Reviewers also see exactly what a spec change produced, in the same diff, instead of trusting that it generates something sensible.
The payoff is that the docs and the code cannot disagree, because one is generated from the other and the compiler checks the seam. The Swagger UI you get out of the spec is real, because the spec is what the server actually implements. I have shipped enough APIs where the docs were a separate, rotting lie to never want to maintain two of these by hand again.
The client gets it for free
And the spec earns its keep on the other side of the wire too. Anyone consuming the API points the same openapi.yaml at their own generator and gets a typed client out the other end, the models and the request and response types, in whatever language they build in. A mobile app, or a service two teams over, generates against the exact same contract the server was generated from, so both ends move together when the spec changes. Nobody is reading your docs and hand-rolling structs that quietly drift from your API.
That is the real reason a single source of truth is worth the discipline. One file describes the API, the server is generated from it, the clients are generated from it, and there is not a hand-written copy of the shapes left anywhere to fall out of sync.