logo hsb.horse
← Back to blog index

Blog

Basic policy when implementing an API server with Golang

I have organized my own basic policies when writing an API server in Go, including single binary + cobra, operation focus, HTTP/CLI separation, secure error handling, and graceful shutdown.

Published:

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

ItemPolicy
Final productStart with a single binary of cobra
Start HTTP serverStart with serve subcommand like myapp serve
Center unitCenter Operation[In, Out]
transportHTTP and CLI are separated as adapters
frameworkDo not leak chi / gin / Echo outside of transport/http
cross-cuttingHTTP middleware and Operation interceptor are separated
errorSafely translate internal errors with boundaries
shutdownInstead 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.

Terminal window
./myapp serve
./myapp health
./myapp users get 123
./myapp users create --name alice

I 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.

  1. Only one handout is required
  2. Configuration reading, logger, DB initialization, and error handling can be done in one system.
  3. Easy to share bootstrap between HTTP server and CLI
  4. 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.

LayerWhat to put
HTTP middlewareHTTP specific things such as request id, access log, recover, authn, CORS
Operation interceptorvalidation, 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.

ItemRole
CodeClassifications such as bad_request and not_found
PublicWording that can be returned to the client
InternalInternal detailed error
MetaLogs 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.

OrderThings to do
1Receive termination signal on signal.NotifyContext
2Stop new inflow by dropping readiness
3Close the queue consumer / cron / background worker entrance
4drain for a while and wait for the load balancer to be reflected
5Propagate context cancel to handler
6Call server.Shutdown(timeoutCtx)
7Wait for in-flight job completion on WaitGroup / errgroup
8Finally close DB / cache / broker producer
9Use 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.

NameMeaning
myapp healthCLI to directly diagnose dependencies locally
GET /api/healthHTTP 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.

Reference