← Back to the blog

The case for vertical slices in small teams

Layered architectures feel tidy on paper. In a four-person team shipping weekly, they mostly generate meetings. Here's what we do instead.

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.

Quick answerA vertical slice organises code by feature, not by technical layer. Each slice owns its handler, its validation, its data access, and its tests in one folder. You read it top-to-bottom, change it in one place, and delete it in one move. For small teams, that's a strictly better default than layered architectures.

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.go

The 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 Queries struct, 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

Subscribe

More of this, once a month.