Go Microservices: A Practical Project Structure
A practical, minimal project structure for Go microservices: service boundaries, internal packages, configuration, migrations, and Docker — without over-engineering.
The problem
Teams splitting a monolith into services often solve the wrong problem first. They spend weeks debating shared libraries, a message bus, and a service mesh, before a single service has shipped. Meanwhile the actual failure mode of most early microservice efforts isn't infrastructure — it's a fuzzy service boundary: two "services" that share a database, or a Go module structure that lets one service reach into another's internal package and quietly recreate the monolith with extra network hops.
A microservice is not defined by size. It's defined by an independent deployable with its own data store, reachable only over the network. Get that boundary right and the internal structure of each service can — and should — be as boring as a well-organized monolith.
Why it matters
A weak boundary shows up later as an incident, not a code review comment:
- Two services sharing one PostgreSQL database means a schema migration for one service can silently break the other, and there's no way to deploy them independently without coordinating both.
- A service that imports another service's
internal/package (via a workaround, like a shared root module) recreates compile-time coupling — you've paid for network latency and operational overhead without gaining independent deployability. - Introducing a shared internal library across services before there's real, painful duplication adds a synchronized-versioning problem: every consumer now needs to update in lockstep with every change.
None of this is solved by choosing the right message broker. It's solved by being deliberate about what a service boundary actually is.
Defining a service boundary
A service boundary in this context means three things hold at once:
- It owns its data. No other service reads from or writes to its database directly. Every cross-service read goes through its API.
- It deploys independently. Shipping a change to one service doesn't require redeploying another.
- It communicates over the network, using a stable contract (HTTP/JSON, gRPC, or an event) — never a shared Go package for business logic.
If two "services" fail any of these, they're really one service with two binaries, and splitting them added cost without adding the benefit microservices are supposed to provide.
A practical project structure
Within a service, the cmd/ + internal/ convention does real work: internal/
is enforced by the Go compiler — no package outside the module tree rooted at
internal/'s parent can import it. That gives you a boundary between "this
service's implementation details" and anything else, including other
services in the same monorepo, for free.
A single service — call it the orders service — looks like this:
orders/
├── cmd/
│ └── api/
│ └── main.go # entrypoint: config, wiring, server start
├── internal/
│ ├── config/ # environment variable loading + validation
│ ├── handler/ # HTTP handlers (transport layer)
│ ├── middleware/ # request ID, logging, recovery, auth
│ ├── service/ # business logic
│ ├── repository/ # database access (SQLC-generated or hand-written)
│ └── model/ # domain types shared across the layers above
├── migrations/ # SQL migrations, versioned
├── tests/ # integration tests against a real database
├── Dockerfile
├── docker-compose.yml # this service + its own Postgres, for local dev
├── go.mod
└── README.mdThis is deliberately identical in shape to a well-structured monolith's internal layout — handlers call services, services call repositories, repositories own the SQL. The only difference from a monolith is scope: this tree describes one bounded piece of the system, deployed and scaled on its own.
Wiring a service
main.go stays a thin composition point — it loads config, constructs the
dependency chain, and starts the server. No business logic lives here:
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("load config: %v", err)
}
db, err := sql.Open("pgx", cfg.DatabaseURL)
if err != nil {
log.Fatalf("connect to database: %v", err)
}
defer db.Close()
repo := repository.NewOrderRepository(db)
svc := service.NewOrderService(repo)
handler := handler.NewOrderHandler(svc)
router := chi.NewRouter()
router.Use(middleware.RequestID)
router.Use(middleware.Logging)
router.Mount("/api/v1/orders", handler.Routes())
srv := &http.Server{
Addr: cfg.Addr,
Handler: router,
ReadTimeout: 5 * time.Second,
WriteTimeout: 10 * time.Second,
}
log.Printf("orders service listening on %s", cfg.Addr)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server error: %v", err)
}
}Packaging the service
Each service gets its own multi-stage Dockerfile, so the shipped image contains only the compiled binary, not the Go toolchain:
# syntax=docker/dockerfile:1
FROM golang:1.22-alpine AS build
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /bin/orders-api ./cmd/api
FROM alpine:3.19
RUN apk add --no-cache ca-certificates
COPY --from=build /bin/orders-api /usr/local/bin/orders-api
EXPOSE 8080
ENTRYPOINT ["orders-api"]A Makefile keeps the common operations for that one service self-contained,
which matters once you have several services in a monorepo and don't want a
top-level build system that couples them together:
run:
go run ./cmd/api
migrate-up:
migrate -path migrations -database "$(DATABASE_URL)" up
docker-build:
docker build -t orders-api:local .Production considerations
Configuration is per-environment, not per-service-type. Each deployed instance of a service reads its own environment variables (database URL, downstream service addresses, log level) — don't bake environment-specific values into the image.
Migrations ship with the service, not separately. Because the service owns its database, its migrations directory is part of its deployable unit. Run them as an explicit step in the deploy pipeline before the new version starts accepting traffic, not as an ad hoc manual step.
Observability is per-service from day one. Structured logs with a
consistent request ID field, and a /health and /ready endpoint per
service, are cheap to add up front and expensive to retrofit once you have
five services and no consistent way to trace a request across them.
Resist adding a shared internal/ library across services until real
duplication is causing real pain — a bug fixed in three places, or a
behavior that's already drifted between services. Until then, a small amount
of duplicated code between independently deployed services is cheaper than
the coordination cost of a shared dependency every service must upgrade in
lockstep.
Common mistakes
- Sharing one database across two services. This is the single most common way teams end up with "microservices" that can't actually be deployed or scaled independently — a schema change becomes a two-team negotiation.
- Reaching into another service's
internal/package via a shared Go module root, which defeats the compiler-enforced boundaryinternal/exists to provide and quietly reintroduces monolith-style coupling. - Splitting a service before there's a reason to. Team ownership, independent scaling needs, or genuinely different reliability requirements are reasons to split. "It felt like it was getting big" is not — a large, well-layered package inside one service is easier to operate than three prematurely split services with a shared database.
- Requiring a message bus on day one because the architecture is called "microservices." Plenty of service-to-service communication is fine as a direct HTTP or gRPC call with a sane timeout; introduce asynchronous messaging when you have an actual case for decoupling in time (retries, fan-out, buffering bursts), not by default.
Summary
A microservice is defined by data ownership, independent deployability, and
network-only communication — not by directory structure or line count.
Inside that boundary, the internal layout should look like a well-organized
monolith: cmd/ for entrypoints, internal/ to enforce the boundary at
compile time, and a clean handler → service → repository flow. Add shared
libraries, message buses, and service meshes when a concrete problem
justifies them, not because the word "microservices" implies you need them
from the start.
funcRelated()[]Article
Circuit Breakers in Go: Preventing Cascading Failures
Implementing a circuit breaker in Go to stop cascading failures — closed/open/half-open states, and why combining it with retries incorrectly makes things worse.
Event-Driven Architecture with Go: Events, Consumers and Failure Handling
Designing event-driven Go services: commands vs events, consumer groups, ordering, and why every consumer must handle duplicate delivery.
Observability for Go Microservices: Logs, Metrics and Traces
The three pillars of observability for Go microservices — structured logs, Prometheus metrics, and distributed traces — and how they differ from monitoring.