May 8, 2026

Structuring a Go project past the "just main.go" stage

gobackend
Go cover graphic for erkshitiz.com.np

Every Go service I’ve written started the same way: one main.go, a handful of routes, a database connection opened at the top, everything in the same file because there was nothing to gain from splitting it up yet. That’s the right call early on. The problem is nobody ever goes back and restructures it once it stops being small, so six months later you’ve got an eight-hundred-line file with HTTP handlers, SQL queries, config parsing, and business logic all reading each other’s local variables.

I hit this on a service that started as an internal tool and quietly became something three other services depended on. Adding a new endpoint meant scrolling past unrelated handlers to find where routes were registered, and testing anything meant spinning up a real database because the handler functions called sql.DB methods directly. Nothing was actually broken, it just got slower to work in every week.

The fix wasn’t a rewrite, it was pulling things apart along the boundaries that were already implicit in the code. I didn’t reach for a full layered architecture with ports and adapters and a dozen interfaces per concept, that’s more ceremony than a service like this needs. I just split by responsibility: an entry point, an HTTP layer, a data layer, and config.

Before, everything lived in one file:

myservice/
  main.go
  go.mod

After, the same service split into a small number of packages, each owning one job:

myservice/
  cmd/
    myservice/
      main.go
  internal/
    httpapi/
      router.go
      handlers.go
    store/
      store.go
      postgres.go
    config/
      config.go
  go.mod

cmd/myservice/main.go does almost nothing now, it just wires the pieces together: load config, open a store, build the router, start listening. internal/config reads environment variables into a typed struct once, instead of every package calling os.Getenv wherever it feels like it. internal/store owns everything that touches the database. internal/httpapi owns routing and handlers, and it talks to the store through an interface instead of a concrete *sql.DB.

That interface is the part that actually paid off:

package store

type UserStore interface {
	GetUser(ctx context.Context, id string) (User, error)
	CreateUser(ctx context.Context, u User) error
}

type PostgresStore struct {
	db *sql.DB
}

func (s *PostgresStore) GetUser(ctx context.Context, id string) (User, error) {
	var u User
	err := s.db.QueryRowContext(ctx, `SELECT id, email FROM users WHERE id = $1`, id).
		Scan(&u.ID, &u.Email)
	return u, err
}

And the handler in internal/httpapi only knows about UserStore, not Postgres:

package httpapi

type UserHandler struct {
	store store.UserStore
}

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
	id := r.PathValue("id")
	u, err := h.store.GetUser(r.Context(), id)
	if err != nil {
		http.Error(w, "not found", http.StatusNotFound)
		return
	}
	json.NewEncoder(w).Encode(u)
}

Once the handler depends on an interface, testing it stops requiring a real database. A fake UserStore that returns canned data is a few lines, and suddenly the HTTP layer has tests that run in milliseconds. That was the actual motivation for splitting things up, not the folder layout for its own sake.

The one thing I’d tell myself earlier: don’t do this on day one. A brand new service with two endpoints doesn’t need cmd/ and internal/ and an interface for its one database table, that’s just indirection you’ll have to read through before the project has earned it. The signal to restructure is concrete, when you notice you’re scrolling to find things, or when you want to test a handler and can’t without a live database. Structure should follow pain, not precede it.