Dev Skills

What the famous packages look like in code

The follow-up to Packages: how a real module splits into many packages, what internal/ hides, why imports cannot go in a circle, and what the popular libraries look like in code.

Topic 51 of 98 · 10 min to read · 45 min to build
After this topic you can
  • Lay out a module with several packages, and use internal/ to keep a package private to it.
  • Fix an "import cycle not allowed" error by moving the shared type into its own package.
  • Recognise the same HTTP route written with net/http, chi, gin, echo and fiber.
  • Compare a database query written with database/sql, sqlx, sqlc and GORM.
  • Find the entry point, routes and storage of an unfamiliar Go repo in a few minutes.

Before the libraries: a module with many packages

This builds on Packages (in Core language), which covered one folder = one package, capitalised names being exported, and import paths. That is not repeated here. This section is what changes once a module has more than two packages.

shop/
go.modmodule example.com/shop
cmd/
api/
main.gopackage main: wires everything together
internal/only code inside shop/ may import these
order/package order: business rules
store/package store: talks to the database
money/package money: other modules may import this
a typical multi-package module

internal/ is enforced by the compiler

  • A package under a folder named internal can be imported only by code rooted at the parent of that internal folder. example.com/shop/cmd/api may import example.com/shop/internal/order; another module that tries gets use of internal package not allowed.
  • Use it for everything you do not want to promise to other people. You can rename or delete an internal package without breaking anyone outside your module.
  • cmd/<name>/main.go is the convention for programs: one folder per binary, each package main, built with go build ./cmd/api.

Imports cannot go in a circle

If order imports store and store imports order, the build stops with import cycle not allowed. Go needs to compile each package after the ones it imports, so a circle has no starting point.

The usual fix: move the type both sides need into a small third package (or into the lower one), so imports only point one way. Another fix: the lower package declares a small interface for what it needs, and the upper package passes in something that satisfies it.

internal/store/store.go
// Before: order imported store for Save, and store imported order for
// the Order type: a cycle. After: the shared type lives in the lower
// package, store imports nothing of ours, and order imports store.
package store
type Order struct{ ID, Total int }
func Save(o Order) error { return nil }
// in package order: store.Save(store.Order{Total: 500})

Why compare them side by side

Reading a package's name tells you nothing. Reading twenty lines of it tells you almost everything — its style, what it takes over, and how hard it would be to leave.

Every block below does the same job, written with each of the popular options. You are not learning all of these. You are learning to open an unfamiliar Go repo and quickly tell what you are looking at.

One HTTP route, five ways

net/http — the standard library (Go 1.22+)
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"id": id})
})
// for real use, an http.Server with timeouts: see "Middleware and graceful shutdown"
http.ListenAndServe(":8080", mux)
No dependency. Since 1.22 this handles method + path params, which was the main reason people added a router.
chi — a thin router, still standard handlers
r := chi.NewRouter()
r.Use(middleware.Logger) // chi ships common middleware
r.Route("/users", func(r chi.Router) { // route groups
r.Get("/{id}", func(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
render.JSON(w, r, map[string]string{"id": id})
})
})
http.ListenAndServe(":8080", r) // hardened version: see "Middleware and graceful shutdown"
The handler signature is still `func(http.ResponseWriter, *http.Request)`, so every standard middleware works. That is chi's whole selling point.
gin — the most popular full framework
r := gin.Default() // logger + recovery included
r.GET("/users/:id", func(c *gin.Context) { // its OWN context type
id := c.Param("id")
c.JSON(http.StatusOK, gin.H{"id": id}) // gin.H is map[string]any
})
r.Run(":8080")
`*gin.Context` carries both the request and the response, which is convenient and is also what ties your handlers to gin. It does satisfy the `context.Context` interface, but by default it does not carry the request's cancellation, so pass `c.Request.Context()` to database calls.
echo — same idea, returns an error
e := echo.New()
e.GET("/users/:id", func(c echo.Context) error { // handlers RETURN an error
return c.JSON(http.StatusOK, map[string]string{"id": c.Param("id")})
})
e.Start(":8080")
Handlers return an error, and echo's central error handler turns it into a response. That saves each handler writing its own error JSON.
fiber — Express-style, and NOT built on net/http
app := fiber.New()
app.Get("/users/:id", func(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"id": c.Params("id")})
})
app.Listen(":8080")
Fiber sits on `fasthttp`, so standard `net/http` middleware and `httptest` do not work with it without an adaptor. Know that before choosing it.
Note: net/http and chi share one handler type, so code moves between them freely. gin, echo and fiber each invent their own, so every handler you write is tied to that framework. That lock-in is the main cost, and feature comparisons rarely mention it.

One database query, four ways

database/sql — the standard library
var u User
err := db.QueryRowContext(ctx,
`SELECT id, name, email FROM users WHERE id = $1`, id,
).Scan(&u.ID, &u.Name, &u.Email)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
You write the SQL and you write the Scan. Verbose, and you can see what runs.
sqlx — same SQL, it does the scanning
var u User // struct tags: `db:"email"`
err := db.GetContext(ctx, &u,
`SELECT id, name, email FROM users WHERE id = $1`, id)
var users []User
err = db.SelectContext(ctx, &users, `SELECT id, name FROM users LIMIT $1`, 20)
A thin layer over database/sql. You keep full control of the SQL and lose the Scan boilerplate. The smallest useful step up.
sqlc — you write SQL, it generates the Go
-- query.sql
-- name: GetUser :one
SELECT id, name, email FROM users WHERE id = $1;
// then run: sqlc generate
// and call the generated, fully typed method:
u, err := q.GetUser(ctx, id) // returns (User, error) — no reflection, no tags
Compile-time safety with hand-written SQL. If you dislike ORMs but hate Scan, this is the one to look at.
GORM — a full ORM
var u User
db.First(&u, id) // SELECT ... WHERE id = ? LIMIT 1
db.Where("age > ?", 18).Order("name").Find(&users)
db.Model(&u).Update("name", "new")
// associations, hooks and auto-migration come with it:
db.AutoMigrate(&User{}, &Order{})
db.Preload("Orders").Find(&users) // loads the related rows
Least typing, least visibility. The query is not in your code, and sooner or later GORM generates one you have to fight. Fine for CRUD-heavy apps; know what you are trading.

One test, two ways

testing — the standard library
got, err := Add(2, 3)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != 5 {
t.Errorf("Add(2, 3) = %d, want 5", got)
}
testify — assertions
require.NoError(t, err) // require: stops the test on failure
assert.Equal(t, 5, got) // assert: records it and carries on
assert.Len(t, users, 3)
assert.Contains(t, body, "not found")
assert.ErrorIs(t, err, ErrNotFound)
`require.NoError` is a common reason teams adopt testify. It replaces three lines with one, and the failure messages are good.

One CLI, and one logger

cobra — the CLI framework behind kubectl and gh
var rootCmd = &cobra.Command{
Use: "app",
Short: "does the thing",
}
var serveCmd = &cobra.Command{
Use: "serve",
Short: "start the HTTP server",
RunE: func(cmd *cobra.Command, args []string) error {
port, _ := cmd.Flags().GetString("port")
return run(port)
},
}
func init() {
serveCmd.Flags().String("port", "8080", "port to listen on")
rootCmd.AddCommand(serveCmd) // app serve --port 9000
}
For a single command, the stdlib `flag` package is enough. Cobra earns its place once you have subcommands.
slog vs zap — the same log line
// log/slog — standard library, Go 1.21+
slog.Info("request completed",
"method", r.Method, "path", r.URL.Path, "status", 200, "ms", 14)
// zap — faster, more ceremony, typed fields
logger.Info("request completed",
zap.String("method", r.Method),
zap.String("path", r.URL.Path),
zap.Int("status", 200),
zap.Int("ms", 14))
Both produce structured JSON. zap is measurably faster and noticeably wordier. Start with slog and move only if a profile tells you to.

How to read an unfamiliar Go repo

  • Open `go.mod` first. The dependency list tells you the framework, the database approach and the logging style before you read any code.
  • Find `func main`. It is under cmd/ in many projects, and it shows how everything is wired, often on one screen.
  • **A *gin.Context or echo.Context in a handler** means every handler is tied to that framework.
  • `db.First(&x)` or `AutoMigrate` means GORM. `.Scan(&` means database/sql. A `query.sql` file means sqlc.
  • `assert.` or `require.` means testify. `zap.String(` means zap.
Worth knowing: You now know what most Go codebases are made of. The track deliberately used almost none of these, so you learned the layer underneath first. That is what makes each of them easy to pick up when a job needs one.
The short version
  • net/http and chi share the standard handler type; gin, echo and fiber each invent their own.
  • Database options in order of control: database/sql → sqlx → sqlc → ent/GORM.
  • require.NoError is why teams adopt testify; the mocks and suites are the parts to think twice about.
  • Read go.mod and func main first when opening an unfamiliar repo.
Remember: You are not learning every framework. You are learning to recognise which one a codebase uses, and what that choice locked in.
Build this before moving on
Write the same "get a user by id" endpoint twice — once with net/http, once with chi or gin. Then count what you would have to change to move between them.
Then try these in your own editor
  1. 1Find a Go project on GitHub, open its go.mod, and predict its framework, database approach and logger before reading any code.
  2. 2Take a net/http handler and port it to gin. Then port it back.
  3. 3Write one query with database/sql, then with sqlx, and count the lines.
Check yourselfTake the Modules and packages test