Table of contents
A REST service is one operating system process. It starts, reads its configuration, opens some sockets and some database connections, then accepts connections until something tells it to stop. Everything else in this guide is a detail of that sentence.
From the outside it is a set of URLs that return JSON, which says little about the thing you have to deploy, watch and debug at an inconvenient hour.
A service at runtime
It holds listening sockets. A socket bound to a port is an operating system resource the process owns for its whole life. Nothing reaches the code until one is bound, and a bound socket is reachable from wherever the network says it is, whatever the code intended. That is why this service has three, per page 17.
It serves many requests at once. Go’s HTTP server runs each connection in its own goroutine, so two requests inside the same function at the same time is the normal case. Anything a handler can reach that was not created per request is shared, and shared means it has to be safe to use concurrently. Page 02 has the practice.
It outlives its requests. A connection pool, an HTTP client and a cache are created once at startup and borrowed by requests that last milliseconds. Sizing, health-checking and closing them is where a service usually falls over under load. Pages 11 and 12.
Something is watching it. An orchestrator, a systemd unit or a supervisor decides whether the process is healthy, sends traffic to it, and restarts it, communicating by calling HTTP endpoints on a schedule it owns and by sending signals. The service’s side of that conversation is pages 13 and 19.
The picture
Read it top to bottom: callers at the top, a request working down through the layers, dependencies at the bottom. The box labelled “service process” is the program you are building, and what sits above and below it usually belongs to somebody else. The sections below name each part in the order the request meets it.
The edge
Between a public client and the public listener sits a box marked “edge”: an ingress, an API gateway, a CDN, a load balancer, a mesh sidecar, or several stacked. It is drawn unlabelled because the product does not matter and the boundary does. Rate limiting, TLS termination, compression, request size caps and sometimes authentication commonly run there already, and writing them again in Go is often wasted work. Page 08 takes each in turn.
The other two callers bypass the edge: a cluster caller on the private listener is another service inside the same trust zone, and an operator or metrics scrape on the monitoring listener is infrastructure. Page 17 is about what has to be true for that to be safe.
The three listeners
The service binds three ports, and each has a different audience.
:8073 is public. Whatever the product exposes to the outside world lands
here, and this is the traffic to assume is hostile.
:8072 is private: administrative operations, internal lookups, and anything
else that would be a liability on the public port.
:8071 is monitoring: health, metrics, profiling, diagnostics, and nothing
that serves a product function.
The split is enforcement rather than a naming convention. A route is registered
against one server, so /metrics on the monitoring server is unreachable on the
public port by any request, including a malformed one and one that arrives after
somebody reorders the middleware chain. Three sockets cost three lines of
configuration. Page 07 builds them, and
page 17 argues the boundary.
Router and middleware chain
A router maps an incoming method and path onto a function. Middleware wraps that function, seeing the request on the way in and the response on the way out. Each listener has its own router and its own chain.
The chain carries the cross-cutting work: reading or generating a trace ID, recording request duration, catching a panic before it kills the connection, logging what happened. Order matters, and page 08 has the rules. The three chains are separate objects, so the public one can carry rate limiting the monitoring one does not need.
Handlers
A handler runs for one route: read the request, hand the work to something that knows the business rules, turn the answer into a response. No SQL, no retry loops, no policy about what an item is.
Public and private handlers are separate boxes because they are separate Go
packages, internal/httphandlerpub and internal/httphandlerpriv. They can share
a service layer underneath while exposing different route sets above. Pages
14 to
16 build real ones.
The operational routes
The monitoring listener’s routes come with the server rather than being written by hand:
| Route | What it answers |
|---|---|
/ | An index of the routes this server exposes |
/ip | The public IP address this instance sees itself as |
/metrics | The Prometheus scrape endpoint |
/ping | Is this process alive and serving |
/pprof/* | Go’s profiling endpoints |
/status | Are this process’s dependencies healthy |
/ping and /status answer different questions, and pointing the wrong probe at
the wrong one produces either a restart loop or a service that receives traffic
before its database is reachable, per
page 13. /metrics and /pprof must never
appear on a public port. The private and public servers enable only /ping.
The service layer
Business rules: what an item is, what a name may contain, how large a page may be. This layer knows nothing about HTTP, taking Go values and returning Go values or errors, which makes it testable without a server and reusable from a queue consumer later.
The reference implementation ships one, internal/item, behind four public
endpoints. It is small on purpose and it is real:
Part V reads it line by
line and then covers what it leaves out.
The repository and the two databases
The repository turns Go values into SQL and back, and is the only place in the service that knows a column name.
There are two connections. The main connection carries writes and the reads that have to see them. The read connection points at a replica, carries queries that tolerate replication lag, and is sized independently. A service with no replica points both at the same database and loses nothing, and keeping them separate from the start makes the day a replica appears a configuration change.
Each connection is a pool. Page 12 covers the difference and the settings that decide whether a slow query becomes a queue.
Outbound clients
Calls the service makes to other services. Each needs a timeout, structured logging of what went out and came back, metrics, and the trace ID forwarded. Most need a retry policy, which is more dangerous than it looks: retrying into a struggling upstream is how a slow dependency becomes a dead one. Page 11.
The reference service has one, a client for ipify behind the /ip route, and it
is excluded from the health checks on purpose. A service that reports itself
unhealthy when a third-party address lookup is down has made its availability
depend on something it does not need.
The cross-cutting parts
Configuration, the logger, the metrics client, the health checks and the shutdown machinery are drawn as a legend rather than a box in the flow, because they sit at no single point in the request path. They are created once during startup and handed to everything that needs them.
That happens in one function, bind, in internal/cli/bind.go, which holds the
entire dependency graph in one readable place and is the file to open first in a
service you have never seen. Page 03 walks the
path that reaches it, page 05 covers the
lifecycle objects it threads through everything, and
page 20 is about keeping it readable as the
service grows.
What this guide covers, and what it does not
The table of contents lists what is covered. What is not:
- Authentication and authorisation as a subject. Page 17 covers the access control a separate listening socket can enforce, and where an authentication middleware attaches. Issuing and verifying credentials outgrows a page. Starting points: the JWT article, the password hashing article, and nurago.org’s security notes.
- gRPC, GraphQL, WebSockets, server-sent events. Different protocols with different lifecycles.
- Email, OAuth flows, file uploads, payment integration. Product features rather than service infrastructure.
- Choosing a database. The examples are MySQL through
database/sql, and everything except the DSN and the migration syntax applies to PostgreSQL.
The toolkit is substitutable
The guide builds with nurago, a set of Go
packages covering the infrastructure concerns above, and every page starts from a
problem before reaching for one. Where the standard library already solves
something, the guide says so before showing the wrapper: net/http serves HTTP
perfectly well, and the httpserver package on top of it is startup validation, a
route binder, operational endpoints and shutdown choreography.
Where a reasonable engineer would pick a different tool, the guide names it: chi or echo, zerolog or zap used directly, sqlc or an ORM, a service mesh instead of an in-process retrier. The test to apply as you read is whether the page still tells you how to build the service if you never install any of it.
Following along
The reference implementation is a complete, deployable service carrying one small
feature, the /items endpoints
Part V reads. Clone the
repository, edit project.cfg in the root, and run make project: it copies
examples/service into target/<cvspath>/<name>/ and substitutes your project’s
name, description, author and repository path through every filename and every
file in the tree. This guide used these values:
NURAGOEXAMPLE=INVENTORYSVC
nuragoexample=inventorysvc
nuragoexampleshortdesc=Inventory service
nuragoexamplelongdesc=Tracks named items and the quantity held of each
nuragoexampleauthor=Example Author
nuragoexampleemail=author@example.com
nuragoexamplecvspath=github.com/example
nuragoexampleprojectlink=https://github.com/example/inventorysvc
nuragoexampleowner=example
nuragoexamplevcsgit=https://github.com/example/inventorysvc.git
The names on the left are the template’s placeholder tokens and stay as they are;
only the values change. The result is a module at github.com/example/inventorysvc,
the import path in every Go snippet in
Part V.
target/ is nurago’s build directory: Git ignores it and make clean deletes it.
Move the generated tree out of the clone before working in it. make project drops
the replace directive from the generated go.mod, so the project resolves nurago
from the module proxy and needs nothing from the checkout. git init it and point
the remote at the repository named in project.cfg.
mv target/github.com/example/inventorysvc ../inventorysvc
cd ../inventorysvc
make deps
go mod tidy
make test
make build
make build writes the binary to target/usr/bin/inventorysvc. Start it from the
project root:
./target/usr/bin/inventorysvc
It logs its startup to stdout, one JSON object per line, and stays in the
foreground until Ctrl-C. No config file is needed: the built-in defaults bind
the three ports and leave the database disabled. --configDir resources/etc/inventorysvc
points it at the file the template ships, and
page 04 covers the search order.
From another shell:
curl localhost:8071/status
curl localhost:8071/ping
curl localhost:8073/uid
curl localhost:8073/items
The first two answer from the monitoring listener and the rest from the public
one, and curl localhost:8071/ lists the operational routes above.
/items is a 404, which is the interesting one. The item endpoints need the
database, the default configuration leaves it disabled, and the routes are
therefore never registered rather than registered against a handler that can only
fail. make localenvup brings up the service against a migrated MySQL in
containers, which is where page 14 picks
them up.