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.
- 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.
internal/ is enforced by the compiler
- A package under a folder named
internalcan be imported only by code rooted at the parent of thatinternalfolder.example.com/shop/cmd/apimay importexample.com/shop/internal/order; another module that tries getsuse 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.gois the convention for programs: one folder per binary, eachpackage main, built withgo 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.
// 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 storetype 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
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)r := chi.NewRouter()r.Use(middleware.Logger) // chi ships common middlewarer.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"r := gin.Default() // logger + recovery includedr.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")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")app := fiber.New()app.Get("/users/:id", func(c *fiber.Ctx) error { return c.JSON(fiber.Map{"id": c.Params("id")})})app.Listen(":8080")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
var u Usererr := 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}var u User // struct tags: `db:"email"`err := db.GetContext(ctx, &u, `SELECT id, name, email FROM users WHERE id = $1`, id)var users []Usererr = db.SelectContext(ctx, &users, `SELECT id, name FROM users LIMIT $1`, 20)-- query.sql-- name: GetUser :oneSELECT 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 tagsvar u Userdb.First(&u, id) // SELECT ... WHERE id = ? LIMIT 1db.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 rowsOne test, two ways
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)}require.NoError(t, err) // require: stops the test on failureassert.Equal(t, 5, got) // assert: records it and carries onassert.Len(t, users, 3)assert.Contains(t, body, "not found")assert.ErrorIs(t, err, ErrNotFound)One CLI, and one logger
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}// 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 fieldslogger.Info("request completed", zap.String("method", r.Method), zap.String("path", r.URL.Path), zap.Int("status", 200), zap.Int("ms", 14))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.Contextorecho.Contextin 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.
net/httpandchishare 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.NoErroris why teams adopt testify; the mocks and suites are the parts to think twice about.- Read
go.modandfunc mainfirst when opening an unfamiliar repo.
net/http, once with chi or gin. Then count what you would have to change to move between them.- 1Find a Go project on GitHub, open its
go.mod, and predict its framework, database approach and logger before reading any code. - 2Take a
net/httphandler and port it to gin. Then port it back. - 3Write one query with
database/sql, then withsqlx, and count the lines.