When you start writing an API server in Go, you want to choose a router or framework first. But that’s not what you should really decide first. The first things you want to fix are the execution model, division of responsibilities, error boundaries, and stopping order.
The router can be changed later. A design with a broken stopping order is troublesome to fix later. I think it would be easier to fix this first.
This article is not a general theory of Go, but rather a basic policy memo to help you avoid confusion the next time you implement an API server.
Fix the policy first
| Item | Policy |
|---|---|
| Final product | Start with a single binary of cobra |
| Start HTTP server | Start with serve subcommand like myapp serve |
| Center unit | Center Operation[In, Out] |
| transport | HTTP and CLI are separated as adapters |
| framework | Do not leak chi / gin / Echo outside of transport/http |
| cross-cutting | HTTP middleware and Operation interceptor are separated |
| error | Safely translate internal errors with boundaries |
| shutdown | Instead of just calling server.Shutdown, set the shutdown order to deterministic |
In short, the structure is not centered around the HTTP framework, but centered around operations and lifecycles.
HTTP server is also treated as CLI
The HTTP server is actually started using the CLI. In that case, it would be easier to understand if you grouped them into cobra from the beginning and combined them into one binary.
The recommended public command system is as follows.
./myapp serve./myapp health./myapp users get 123./myapp users create --name aliceI think a two-step approach like server run is redundant. serve completes the meaning and reduces the amount of typing.
The reason for favoring single binary is simple.
- Only one handout is required
- Configuration reading, logger, DB initialization, and error handling can be done in one system.
- Easy to share bootstrap between HTTP server and CLI
- Even if you want to convert to 2 binarization later, you can just increase
cmd/slightly.
On the other hand, choosing separate binaries from the beginning can be done only after you understand the reason, such as wanting to make the server container extremely small, having significantly different dependencies and privileges between server and cli, or wanting to separate the frequency of releases.
The core of the implementation is in Operation
If you design around the HTTP handler, the convenience of routers and frameworks will be deeply involved. Then, when you want to extend the CLI, you tend to write the same process again.
It is better to center it on Operation.
type Operation[In any, Out any] interface { Execute(ctx context.Context, in In) (Out, error)}Whether it’s users get or users create, just call the same Operation for HTTP and CLI. HTTP is an adapter that converts request/response, and CLI is an adapter that converts flag/stdin/stdout.
If you do this, the core of the application will not change even if you increase the number of transports.
Framework is confined to transport/http
Whether to use chi, gin, or Echo is a matter of transport. It’s not about domains or operations.
What you want to do when choosing a router is not to decide the responsibility of the API server, but just to decide how to use HTTP. Therefore, framework differences are confined under internal/transport/http.
The image looks like this.
internal/ bootstrap/ operation/ interceptor/ transport/ http/ cli/ domain/ infra/ apperr/If you see chi or gin.Context from operation, it’s probably leaking. If this starts to leak, it’s not a matter of changing the framework, it’s a matter of rewriting the entire app.
Do not mix HTTP middleware and Operation interceptor
It will be easier to think about this separately.
| Layer | What to put |
|---|---|
| HTTP middleware | HTTP specific things such as request id, access log, recover, authn, CORS |
| Operation interceptor | validation, authz, timeout, metrics, logging per use case |
HTTP middleware is convenient for transport. Operation interceptor is for application convenience. Even with the same logging, the meaning of the location is different.
Combining these two tends to result in an HTTP-closed design. The moment you add CLI, you will have to rewrite validation and authz with separate implementations.
Convert error safely with boundary
Do not output internal errors directly to the HTTP response or CLI standard output. It’s easier to establish rules here from the beginning.
In your app, use an error that has at least the following four elements.
| Item | Role |
|---|---|
Code | Classifications such as bad_request and not_found |
Public | Wording that can be returned to the client |
Internal | Internal detailed error |
Meta | Logs and auxiliary information |
In HTTP, it translates to status code and JSON. In CLI, it translates to stderr and exit code. The important thing is to do the translation at the boundary. Don’t start thinking about HTTP status on the Operation side.
Graceful shutdown should be designed including the shutdown order
This is a very important point in Go’s API server implementation. It is not treated as “graceful shutdown supported” just by calling srv.Shutdown(ctx).
In net/http official documentation, Shutdown closes the listener, stops new reception, closes the idle connection, and waits for the active connection to return to idle. On the other hand, ListenAndServe returns ErrServerClosed after calling Shutdown or Close. In other words, if you end main only by looking at the return, the process may end while it is stopped.
Furthermore, Shutdown does not wait for hijacked connections. Use RegisterOnShutdown if necessary, and use BaseContext if you want to propagate shutdown to the handler side. If you convert signal into a context using os/signal’s NotifyContext, it will be easier to align the starting point for termination processing.
If you are using Kubernetes, the stop order is even more important. Pod lifecycle gives the Pod a graceful termination period, and container lifecycle hooks continues its countdown while running PreStop. Therefore, if you stop it sloppily, the time will run out before drain is finished.
My basic policy is this order.
| Order | Things to do |
|---|---|
| 1 | Receive termination signal on signal.NotifyContext |
| 2 | Stop new inflow by dropping readiness |
| 3 | Close the queue consumer / cron / background worker entrance |
| 4 | drain for a while and wait for the load balancer to be reflected |
| 5 | Propagate context cancel to handler |
| 6 | Call server.Shutdown(timeoutCtx) |
| 7 | Wait for in-flight job completion on WaitGroup / errgroup |
| 8 | Finally close DB / cache / broker producer |
| 9 | Use server.Close() as a last resort only when timeout is exceeded |
I think the core of this discussion is not so much Go-specific, but rather that you should design your stopping sequences to be deterministic.
There are two types of health
It’s easy to get confused here too.
| Name | Meaning |
|---|---|
myapp health | CLI to directly diagnose dependencies locally |
GET /api/health | HTTP health endpoint for starting server |
The same health but different roles. The CLI side is similar to diagnostics, and the HTTP side is similar to readiness / liveness. In particular, it is safer to design the HTTP side health to return 503 during shutdown.
Minimal directory policy
This is enough for the initial skeleton.
cmd/ myapp/internal/ apperr/ bootstrap/ domain/ infra/ interceptor/ operation/ health/ users/ transport/ cli/ http/ version/Keep cmd/myapp thin. Initialization goes to bootstrap, use cases to operation, and HTTP and CLI to transport. Even if you want to convert to 2 binarization later, if you use this cutting method, you can easily just add cmd/myapp-server.
Sentence you want to keep
When implementing a Go API server, the first thing to decide is not the framework, but the execution model, division of responsibilities, error boundaries, and stop order.
This one sentence is usually enough. You can decide whether to use chi or Echo after that.
summary
My basic policy is this. First, combine it into a single binary of cobra and start the HTTP server with serve. The center will be placed on Operation, and HTTP and CLI will be separated as adapters. The framework is confined to transport/http. Error is safely converted using boundary. Shutdown does not end at server.Shutdown, but the order is designed to include readiness, drain, worker stop, and resource cleanup.
If you do this, the design will remain consistent even if you change the framework later, add a CLI, or increase the number of workers.
hsb.horse