Structuring a Go Gin API with Handler, Service, and Repository Layers
Quick answer
Most Gin projects start the same way: a single file with routes, SQL queries, JSON binding, and business rules all mixed together. It is fast to write, and it...
Most Gin projects start the same way: a single file with routes, SQL queries, JSON binding, and business rules all mixed together. It is fast to write, and it works until a second developer joins or a small requirement change forces you to touch six handlers at once. Layering your API is the usual answer, but it is easy to overdo, so the real question is where each responsibility should live and how much structure your service actually needs.
What Each Layer Is Responsible For
The handler layer owns everything that is specific to HTTP. It reads the request, binds and validates the shape of the input, calls the service, and translates the result or error into a status code and a JSON body. If a function needs a gin.Context, it belongs here. A handler should be boring: if you find yourself writing loops, pricing rules, or permission decisions inside it, that logic is in the wrong place.
The service layer owns business rules. It decides whether an order can be cancelled, whether a user may change a plan, or which steps must succeed together. It receives plain Go values and a standard context.Context, and it returns domain results or typed errors. Because it knows nothing about HTTP, you can test it without spinning up a router, and you can reuse it from a CLI command, a queue consumer, or a scheduled job.
The repository layer owns persistence. It turns domain operations into SQL (or calls to another datastore) and maps rows back into structs. It should not contain business decisions, but it is the right place for query details such as pagination, ordering, and locking clauses, because those depend on the database in use.
A Minimal Structure in Go
A layout that scales well for small and mid-sized APIs groups code by feature first and by layer second. Each feature folder holds its own handler, service, repository, and types, which keeps related changes close together:
internal/
orders/
handler.go
service.go
repository.go
model.go
users/
handler.go
service.go
repository.go
cmd/api/main.go
The dependency direction matters more than the folder names. The handler depends on the service, the service depends on the repository, and nothing depends upward. In Go, a useful habit is to define interfaces where they are consumed, not where they are implemented. The service declares the small interface it needs from storage, and the concrete repository satisfies it implicitly:
// in orders/service.go
type Store interface {
Create(ctx context.Context, o Order) (Order, error)
ByID(ctx context.Context, id int64) (Order, error)
}
type Service struct {
store Store
}
func NewService(store Store) *Service {
return &Service{store: store}
}
func (s *Service) Place(ctx context.Context, in PlaceInput) (Order, error) {
if len(in.Items) == 0 {
return Order{}, ErrEmptyOrder
}
return s.store.Create(ctx, newOrder(in))
}
The handler then stays thin. It binds the body, calls Place, and maps ErrEmptyOrder to a 422 or 400 response. The wiring of concrete types happens once, in main.go, which keeps constructors explicit and avoids hidden global state.
Common Pitfalls
The most frequent mistake is passing *gin.Context into the service. It feels convenient because the context carries request-scoped values, but it couples your business logic to the web framework and makes tests awkward. Extract what you need in the handler (the authenticated user ID, for example) and pass it as an argument, along with c.Request.Context() so cancellation and deadlines propagate to the database call.
Another pitfall is returning database models straight to JSON. When the same struct represents a table row and an API response, a schema change silently becomes an API change, and fields you never meant to expose can leak. Using separate request and response types costs a few extra lines but gives you a stable contract.
A third problem is error handling that loses meaning. If the repository returns a raw driver error and the handler maps everything to 500, clients cannot tell a missing record from a conflict. Define a small set of sentinel or typed errors in the service layer (not found, conflict, invalid input) and let the handler translate them with errors.Is or errors.As.
- Do not call the database from handlers, even for "simple" reads that seem too small for a service.
- Do not create an interface for every struct. Add one when a second implementation or a test double actually needs it.
- Do not put transactions in handlers. Let the service decide the transaction boundary, and let the repository accept a transaction handle.
When Layering Is Too Much
Layers have a cost: more files, more indirection, and more boilerplate to pass data across boundaries. For a tiny internal API with three CRUD endpoints and no business rules, a service that only forwards calls to a repository is noise. A reasonable middle path is to start with handler plus repository, and introduce a service the first time a rule appears that involves more than one repository or that you want to reuse outside HTTP.
The goal is not architectural purity. It is keeping the parts that change for different reasons (HTTP details, business rules, storage details) from changing together. If your current structure already achieves that, adding more layers will not make it better.
Frequently Asked Questions
Should the service layer return HTTP status codes?
No. The service should return domain results and typed errors. Mapping those to status codes is the handler's job, which keeps the service reusable outside HTTP.
Where should input validation go?
Validate the shape of the request (required fields, formats, ranges) in the handler, using binding tags or a validator. Validate business invariants, such as whether a coupon is still valid, in the service.
Do I need a dependency injection framework in Go?
Usually not. Manual constructor wiring in main.go is explicit, easy to read, and enough for most services. Consider a code generation tool only if wiring grows large enough to become a maintenance burden.
Is feature-based or layer-based folder structure better?
Feature-based grouping tends to scale better because a change to one feature touches one folder. Layer-based folders (all handlers together, all repositories together) work for small projects but spread a single feature across many directories.
Summary
Split a Gin API so that handlers deal with HTTP, services deal with business rules, and repositories deal with storage, and keep dependencies pointing one way. Define interfaces at the consumer, pass context.Context instead of gin.Context, and introduce layers only as fast as your logic actually demands them. For framework-specific behavior such as binding and middleware ordering, check the official Gin documentation for the version you use.