Skip to main content

Someone wanted to learn this too, so Grasp built them a personal learning path.

You can keep exploring this one or create your own

Building a Runnable Go Service with Layered Package Dependencies

Hello. Your first-pass booking contract now says what clients should eventually observe, while keeping unresolved decisions visible. This lesson gives that design a runnable home: a Go module with an executable under cmd and private application code under internal. The goal is not to implement bookings yet. It is to prove that you can start from an empty repository, choose package boundaries, start an HTTP process, and verify that the dependencies point in the intended direction.

Allow about 40–45 minutes. Treat the first pass as an independent build; use documentation for exact APIs, but do not look for a ready-made service skeleton.


Set a narrow target before opening the editor

For this build, “runnable” has an observable meaning: the process starts, GET /healthz returns 200 OK with ok, and an unregistered path returns 404 Not Found. If the chosen port is occupied, startup must fail visibly rather than appear successful. No booking route is required today. An in-memory create-and-get slice is the next lesson’s work.

Attempt first — about 12 minutes. In an empty directory, try to create that service without reading further. Decide where main belongs, where the HTTP handler belongs, and which package is allowed to import which. Compile and make one request. If you get stuck, record the precise obstacle—module path, import, handler registration, or server startup—before continuing. That record is more useful than an unexamined working skeleton.


Make the executable small and the boundary deliberate

A short visual orientation is useful here. Watch the portions of Melkey’s This Is The BEST Way To Structure Your GO Projects that identify the entry point and private supporting code; treat the layout as a convention, not a mandatory template.

This Is The BEST Way To Structure Your GO Projects

Melkey sketches the roles of cmd, main.go, and internal before building a small server.

Watch the entry point for the distinction between repository files and the executable under cmd. Then watch private code for the purpose of internal. We will use the official Go guidance below to pin down the import rule more precisely.

Go’s Organizing a Go module gives a layout specifically for servers, rather than a universal architecture to copy.

Organizing a Go module

Read the Go documentation’s guidance on supporting packages, commands, and server projects to check which parts of the layout are conventions and which have language-tool support.

In “Package or command with supporting packages,” read the internal rationale. In “Multiple commands,” read the cmd convention. Finally, in “Server project,” read the server guidance and continue through the sample directory tree immediately below it. Focus on why a server need not publish reusable packages.

For this slice, two Go packages are enough:

go.mod
cmd/
  bookingapi/
    main.go
internal/
  httpapi/
    handler.go

cmd/bookingapi is package main: it chooses the listening address, obtains the HTTP handler, and starts the server. internal/httpapi is package httpapi: it registers the health route and returns a handler. Giving the handler package a small public surface—say, NewHandler() http.Handler—lets main use it without owning its routes. Both packages may import the standard library.

The directed dependency today is that main imports httpapi; httpapi does not import main. Later, booking rules can live in an internal/booking package that does not know about HTTP. The handler may then depend on booking behavior, while main wires the pieces together. Do not create that package merely to fill out a directory tree before it has a responsibility.

An important distinction: internal restricts who may import a package from outside its permitted import-path tree. It does not enforce your architectural layering within that tree. Go rejects an import cycle, but an undesirable import can still compile if it does not form a cycle. You must review dependency direction, not infer good boundaries from a successful build.


Initialize, connect, and run

The go command documentation is worth consulting for the exact operations, especially when working from a blank repository.

go command - cmd/go

Use these three short passages as an API reference for creating the module, running its command, and producing a binary; there is no need to read the surrounding command reference.

Under “Initialize new module in current directory,” read module initialization. Under “Compile and run Go program,” read package execution. Under “Compile packages and dependencies,” read build behavior.

If your independent attempt did not get this far, work from the repository root. The following are tool commands, not implementation code:

go mod init example.com/bookinglab
mkdir -p cmd/bookingapi internal/httpapi

example.com/bookinglab is a placeholder module path for a local build; use the intended repository path if you will publish the module. Its exact spelling is otherwise unimportant, but imports must agree with the module line in go.mod. Thus main.go imports example.com/bookinglab/internal/httpapi, not a relative filesystem path.

Write the two files yourself. Keep their responsibilities tight:

  1. In internal/httpapi/handler.go, declare package httpapi. Have NewHandler return an http.Handler that serves /healthz. Register that specific path, not a catch-all / route; otherwise your unregistered-path check may pass through the wrong handler.
  2. In cmd/bookingapi/main.go, declare package main and provide func main(). Use the handler from httpapi to serve on 127.0.0.1:8080. Handle the server’s returned error so a failed listen is visible and exits the process unsuccessfully.

The loopback address is a deliberate local-development choice, not a deployment policy. A health response here establishes only that the process can answer HTTP; it does not claim that a booking store or database is healthy. If you need an exact standard-library signature, go doc net/http.NewServeMux and go doc net/http.ListenAndServe are reasonable lookups. Choosing the package boundary and assembling the service remain your work.

From the repository root, run:

go build ./...
go run ./cmd/bookingapi

In another terminal, check the two observable behaviors:

curl -i http://127.0.0.1:8080/healthz
curl -i http://127.0.0.1:8080/bookings

The second request should be 404 because bookings have not been implemented, not because the service failed to start. To check the executable separately, stop the running process, then build and run the binary:

mkdir -p bin
go build -o ./bin/bookingapi ./cmd/bookingapi
./bin/bookingapi

Keep bin/ out of version control. With only standard-library imports, you need not add dependencies or manufacture a go.sum file.


Inspect the import direction, not just the HTTP response

A successful curl call proves reachability; it does not prove that package boundaries make sense. Open both files and inspect their imports. main should know about httpapi. httpapi should know about HTTP, but not the command’s configuration, startup procedure, or package main. If changing the listening address requires editing handler.go, startup policy has probably crossed the boundary. If changing /healthz requires editing main.go, route ownership may have crossed it in the other direction.

When something fails, locate the boundary before searching for a complete example. A wrong module prefix typically produces an import-resolution error. Running a non-main package will not start a command. A server that logs “starting” but ignores its listen error can mislead you when the address is occupied. These are distinct failures with distinct fixes; replacing the repository with a tutorial skeleton obscures which one you encountered.

Keep a short note of what you could reproduce without help and any exact API you looked up. That is evidence for the later implementation scorecard, not a judgment about whether documentation use was “allowed.”


You now have a testable repository boundary: an executable under cmd, private HTTP code under internal, and imports that point from composition toward supporting behavior. The build, live request, unknown-route response, and failed-start behavior together establish that this is a service rather than just a directory layout. Next, you will use the booking contract to add in-memory create-and-get behavior without letting HTTP concerns become the booking domain.

Can't find a good explanation? Sign up and we'll make it for you

Sign up