12 min read
Implemented in Checkout Kit
Full-stack Stripe checkout starter: product catalog, cart, orders, and admin — React + Go or Node + MongoDB
See Checkout KitYou start with main.go, three endpoints, everything in one file. It's clean, it's fast, and you can read the whole thing on one screen. Then the tenth endpoint arrives, then authentication, then a background job that needs the same logic as one of the handlers — and suddenly nobody can say where a new piece of code is supposed to go.
Go does this to everyone, and it's not an accident.
Rails gives you app/models. Django gives you apps. Spring gives you annotations and a container. Go gives you main.go and a standard library, and it deliberately refuses to tell you where things belong.
That refusal is why Go services stay small and fast. It's also why so many of them turn into a 2.000-line main.go by month three. The framework isn't going to save you, so the layout has to be a decision you make on purpose, early, once.
What follows is the layout we use in production across the Go editions of our kits. It isn't the only correct answer — but it holds up past the tenth endpoint, which is where most layouts stop holding up.
Three responsibilities, three packages, one direction of dependency:
handler → service → repository
HTTP rules storageHandler speaks HTTP and nothing else. It decodes the request, checks that the shape is valid, calls the service, and encodes whatever comes back. It knows about http.Request, status codes and JSON. It must never know that a database exists.
Service holds the business rules. It knows nothing about HTTP and nothing about SQL. Given a customer id and a set of items, it decides whether the order is legal, computes what it costs, and orchestrates the writes.
Repository speaks to storage. It knows SQL or the Mongo driver, and it returns domain types — never sql.Rows, never bson.M.
Here is the whole cycle for one endpoint.
// internal/handler/order.go — HTTP only.
type OrderHandler struct {
orders *service.OrderService
}
func (h *OrderHandler) Create(w http.ResponseWriter, r *http.Request) {
var req CreateOrderRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
httpx.Error(w, apperr.Invalid("malformed json"))
return
}
if err := req.Validate(); err != nil {
httpx.Error(w, err)
return
}
order, err := h.orders.Create(r.Context(), service.CreateOrderInput{
CustomerID: auth.UserID(r.Context()),
Items: req.Items,
})
if err != nil {
httpx.Error(w, err) // one place decides the status code
return
}
httpx.JSON(w, http.StatusCreated, toOrderResponse(order))
}// internal/service/order.go — rules. No HTTP, no SQL.
type OrderService struct {
orders OrderRepository
inventory InventoryRepository
}
func (s *OrderService) Create(ctx context.Context, in CreateOrderInput) (*model.Order, error) {
if len(in.Items) == 0 {
return nil, apperr.Invalid("order must contain at least one item")
}
order := model.NewOrder(in.CustomerID)
for _, it := range in.Items {
stock, err := s.inventory.Reserve(ctx, it.SKU, it.Qty)
if err != nil {
return nil, err
}
order.Add(it.SKU, it.Qty, stock.UnitPriceCents)
}
if err := s.orders.Save(ctx, order); err != nil {
return nil, err
}
return order, nil
}// internal/repository/order_postgres.go — storage only.
type OrderPostgres struct{ db *sql.DB }
func (r *OrderPostgres) Save(ctx context.Context, o *model.Order) error {
const q = `INSERT INTO orders (id, customer_id, total_cents, status, created_at)
VALUES ($1, $2, $3, $4, $5)`
_, err := r.db.ExecContext(ctx, q,
o.ID, o.CustomerID, o.TotalCents, o.Status, o.CreatedAt)
return err
}cmd/
api/main.go composition root — the only file that knows every concrete type
seed/main.go a second entry point, same internals
internal/
handler/ HTTP: routing, decode, encode
service/ business rules
repository/ persistence implementations
model/ domain types — no tags for a specific driver
middleware/ auth, rbac, logging, recovery
apperr/ the one error type
httpx/ JSON + error → status translation
config/ env parsing, in one place
pkg/
rbac/ genuinely reusable, no app-specific imports
migrations/
0001_init.up.sqlTwo things about this that matter more than they look.
internal/ is enforced by the compiler, not by convention. Anything under a directory named internal cannot be imported from outside its parent module. Go itself refuses to compile it. So the boundary is a guarantee, not a promise you make to yourself in a code review.
pkg/ is not "the rest of my code". This is the most common mistake in Go layouts: people create pkg/ and put everything in it, because pkg sounds official. It isn't. Put something in pkg/ only if you would be comfortable with a stranger importing it — no dependencies on your models, no assumptions about your app. If it fails that test, it belongs in internal/.
Our pkg/rbac passes it: about 340 lines, role inheritance, field-level rules, zero imports from anywhere else in the tree. Everything else lives under internal/.
main.go is the only place allowed to know every concrete type. Everything below takes interfaces.
func main() {
cfg := config.Load()
db, err := sql.Open("postgres", cfg.DatabaseURL)
if err != nil {
log.Fatal(err)
}
defer db.Close()
// repositories (concrete)
orders := repository.NewOrderPostgres(db)
inventory := repository.NewInventoryPostgres(db)
// services (take interfaces)
orderSvc := service.NewOrderService(orders, inventory)
// handlers (take services)
orderH := handler.NewOrderHandler(orderSvc)
r := chi.NewRouter()
route.Mount(r, route.Deps{Orders: orderH, Config: cfg})
log.Fatal(http.ListenAndServe(cfg.Addr, r))
}Nothing else in the tree calls sql.Open. Nothing else reads an environment variable. When you need a second entry point — a seeder, a worker, a migration runner — it reuses the same internal/ packages and builds its own object graph. That's what cmd/ is for.
This one trips up people arriving from Java or C#.
In Go, the interface is declared where it is consumed, not where it is implemented. The service package declares what it needs from storage:
// internal/service/order.go
type OrderRepository interface {
Save(ctx context.Context, o *model.Order) error
ByID(ctx context.Context, id string) (*model.Order, error)
}And repository just implements it, without importing service and without declaring anything:
// internal/repository/order_postgres.go
type OrderPostgres struct{ db *sql.DB }
// methods happen to match — no "implements" keyword, no importThe dependency arrow points inward: repository doesn't know service exists. Swap Postgres for Mongo and the service package doesn't change by a character. This is also what makes the tests at the bottom of this article possible.
r.Use(middleware.Recoverer) // 1
r.Use(middleware.RequestID) // 2
r.Use(middleware.Logger) // 3
r.Use(middleware.CORS(cfg)) // 4
// per-route, not global:
r.Group(func(pr chi.Router) {
pr.Use(middleware.RequireAuth(cfg.JWTSecret)) // 5
pr.Use(middleware.RBACGate(policies)) // 6
pr.Post("/orders", orderH.Create)
})The reasoning, top to bottom:
OPTIONS carries no credentials. If auth runs first the browser gets a 401 on the preflight and your frontend gets an error message that has nothing to do with the real problem.The service layer must not know what an HTTP status code is. So it returns a domain error carrying a kind, and exactly one place turns kinds into status codes.
// internal/apperr/apperr.go
type Kind int
const (
KindInvalid Kind = iota + 1
KindNotFound
KindConflict
KindForbidden
KindInternal
)
type Error struct {
Kind Kind
Message string
err error
}
func (e *Error) Error() string { return e.Message }
func (e *Error) Unwrap() error { return e.err }
func Invalid(msg string) *Error { return &Error{Kind: KindInvalid, Message: msg} }
func NotFound(msg string) *Error { return &Error{Kind: KindNotFound, Message: msg} }
func Conflict(msg string) *Error { return &Error{Kind: KindConflict, Message: msg} }// internal/httpx/error.go — the only file that maps domain → HTTP
func Error(w http.ResponseWriter, err error) {
var appErr *apperr.Error
if !errors.As(err, &appErr) {
log.Printf("unhandled: %v", err)
JSON(w, http.StatusInternalServerError, body{"error": "internal error"})
return
}
status := map[apperr.Kind]int{
apperr.KindInvalid: http.StatusBadRequest,
apperr.KindNotFound: http.StatusNotFound,
apperr.KindConflict: http.StatusConflict,
apperr.KindForbidden: http.StatusForbidden,
}[appErr.Kind]
if status == 0 {
status = http.StatusInternalServerError
}
JSON(w, status, body{"error": appErr.Message})
}Two properties fall out of this that are worth the effort. Any error that isn't a domain error becomes a 500 and gets logged — so an unexpected driver error can never leak to the client as a 400 with a database message in it. And when you decide that conflicts should be 409 instead of 400, you change one line, not forty handlers.
Honestly:
net/http — since Go 1.22 the standard mux understands method and path patterns (POST /orders/{id}). For a straightforward JSON API that is genuinely enough, and it means zero dependencies in your routing layer.
Chi — a thin router on top of net/http. Handlers keep the standard func(http.ResponseWriter, *http.Request) signature, so every piece of middleware in the ecosystem works and your handlers are trivially testable with httptest. Route groups make the per-route auth above natural. This is what we use.
Gin — the fastest to write and the largest ecosystem, but handlers take *gin.Context instead of the standard pair. That couples your handler layer to Gin: testing needs Gin's helpers, and moving off it later means touching every handler. Perfectly reasonable choice — just make it knowingly, not because a tutorial used it.
The layers in this article don't depend on which one you pick. Only internal/handler and internal/route know the difference, which is the point.
This is where the separation stops being aesthetic. Because the service depends on an interface it declared itself, a business-rule test needs no HTTP server and no database:
type fakeInventory struct{ stock map[string]int }
func (f *fakeInventory) Reserve(_ context.Context, sku string, qty int) (*model.Stock, error) {
if f.stock[sku] < qty {
return nil, apperr.Conflict("insufficient stock")
}
f.stock[sku] -= qty
return &model.Stock{SKU: sku, UnitPriceCents: 1999}, nil
}
func TestCreateOrder_RejectsWhenOutOfStock(t *testing.T) {
svc := service.NewOrderService(&fakeOrders{}, &fakeInventory{stock: map[string]int{"SKU-1": 1}})
_, err := svc.Create(context.Background(), service.CreateOrderInput{
CustomerID: "cust_1",
Items: []service.Item{{SKU: "SKU-1", Qty: 5}},
})
var appErr *apperr.Error
if !errors.As(err, &appErr) || appErr.Kind != apperr.KindConflict {
t.Fatalf("want conflict, got %v", err)
}
}Milliseconds, no containers, no fixtures. That test is the return on every boundary above.
internal/ for everything specific to this app — the compiler enforces it.pkg/ only for code a stranger could import.cmd/api/main.go. Nothing else opens a database or reads an env var.None of this is clever. It's just decided once, up front, so the tenth endpoint goes in the obvious place.
This is the layout our Go editions ship with — handler / service / repository, a standalone pkg/rbac with field-level rules, and the same API contract mirrored in the Node edition. If you'd rather start from it than assemble it: Checkout Kit and Booking Kit.