Layered architectures look tidy in a diagram. Controllers on top, services below, repositories at the bottom, domain models in the middle. Then you go to add a feature, and you're editing eight files across four packages — none of which you'd ever read together if you weren't forced to. In a four-person team shipping weekly, that overhead compounds fast.
The layered-architecture tax
In a layered codebase, every feature is smeared across the layers. Adding a new endpoint means: a route in the router package, a handler in the handlers package, a service in the services package, a repository in the repository package, a model in the domain package, and tests for each. Six files for one capability.
The argument for layering is that it enforces separation of concerns. In practice, it enforces the appearance of separation. The handler still knows what the service does. The service still knows what the repository returns. The abstraction is mostly ceremonial.
What a vertical slice actually is
A vertical slice is a folder that contains everything for one feature: the HTTP handler, the input validation, the database queries, the domain types specific to this feature, and the tests. If you delete the folder, the feature is gone — no orphan types in domain/, no half-removed methods in services/.
// Layered (the old way)
internal/
handlers/invoices.go // every invoice endpoint
services/invoice_service.go // every invoice operation
repository/invoices.go // every invoice query
domain/invoice.go // every invoice type
// Vertical slices (the small-team way)
internal/features/
createinvoice/
handler.go
handler_test.go
sql.go
types.go
listinvoices/
handler.go
handler_test.go
sql.go
voidinvoice/
handler.go
handler_test.go
sql.goThe shape of a slice in Go
A slice exposes a constructor and an HTTP handler. Everything else is package-private. The handler validates input, calls the queries, and returns the response — no service layer in the middle.
package createinvoice
type Handler struct {
q *db.Queries
ev events.Publisher
}
func New(q *db.Queries, ev events.Publisher) *Handler {
return &Handler{q: q, ev: ev}
}
type request struct {
CustomerID string `json:"customer_id"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var req request
if err := decode(r, &req); err != nil { ... }
if err := req.validate(); err != nil { ... }
inv, err := h.q.CreateInvoice(r.Context(), db.CreateInvoiceParams{...})
if err != nil { ... }
h.ev.Publish("invoice.created", inv.ID)
writeJSON(w, http.StatusCreated, inv)
}What still belongs in shared code
Vertical slices don't mean zero shared code. They mean shared code earns its place. Things that genuinely live in shared packages:
- Database access primitives. The connection pool, the sqlc-generated
Queriesstruct, transaction helpers. - Cross-cutting middleware. Auth, request logging, tracing, rate limiting. Wired once at the router level.
- Domain primitives that are genuinely shared. Money types, tenant IDs, email parsing. Things three or more slices use.
- The router.One file that wires every slice to its path. The slice itself doesn't know its URL.
Resist the urge to extract a "shared" package after the second slice uses something. Wait for the third. The cost of duplication is lower than the cost of a leaky abstraction.
When vertical slices stop working
Vertical slices are a small-team pattern. They work because every engineer can hold the whole codebase in their head, and because cross-slice coordination is rare. At 30 engineers, the trade-offs change: shared domain models become valuable to enforce consistency, and the duplication across slices starts to compound.
The signal to evolve: when the same business rule shows up in five slices and they've started to drift. Extract a domain package, share it, keep the slices. You don't need to rewrite everything — most slices stay vertical forever.
If you're scoping a new Go backend and want a second pair of eyes on the structure, we do architecture reviews. The shape you pick in week one is the shape you'll be living in at month nine.
End of article · Thanks for reading