
Code on laptop with hands typing in the dark Free Photo
When I first started writing Go to make a back-end API, I did what most people do — threw everything into one file. The handler, the business logic, the database calls, all of it crammed together. It worked, until it didn't. The moment I tried to refactor or write tests, three things broke. Sound familiar?
The fix isn't complicated, but it does require a bit of upfront thinking about how you organize your code. In this article I'll walk through a project structure that I learned recently. Companies like Uber and Netflix use similar patterns to manage large systems. It allows for scaling while remaining resilient to failures.
Why Bother Separating Concerns?
Most developers have all written messy code at some point. But once things are separated, the breathing room allows for testing, changes and reuse.
The idea behind separation of concerns is simple: each part of your code should have one job and one job only. When your handler is doing database queries and business logic, changes and tests can get tedious
The Four Layers
For this project we use four layers:
- Model — the data structure
- Repository — database access
- Service — business logic
- Handler — HTTP request handling
The payoff is that the same core logic runs with different routers (standard net/http, Gin, or Chi) and different databases (SQLite or MongoDB) without touching the middle layers at all.
1. Model
This is just the shape of your data. No HTTP, no database — just a structure, or skeleton.
type Todo struct {
ID string
Title string
Completed bool
CreatedAt time.Time
}
Everything else in the app revolves around this.
2. Repository
The repository is the only layer that talks to the database. Everything above it just calls repository methods.
You define an interface first:
type TodoRepository interface {
Create(todo Todo) error
GetByID(id string) (Todo, error)
GetAll() ([]Todo, error)
Update(todo Todo) error
Delete(id string) error
}
Then you implement it for each database. Want to switch from SQLite to MongoDB? Write a new implementation, swap it in — nothing else changes.
3. Service
This is where your actual business logic lives. The service interact with whatever database being used, and it has nothing to do with HTTP. It just takes inputs, does something meaningful with them, and returns a result.
type TodoService struct {
Repo TodoRepository
}
func (s *TodoService) Create(title string) (Todo, error) {
todo := Todo{
ID: uuid.New().String(),
Title: title,
Completed: false,
CreatedAt: time.Now(),
}
err := s.Repo.Create(todo)
return todo, err
}
Notice it only knows about the repository interface. That's the point.
4. Handler
Handlers sit at the edge of the application and deal with HTTP. They decode requests, call the service, and write responses.
func (h *TodoHandler) Create(w http.ResponseWriter, r *http.Request) {
var req struct {
Title string `json:"title"`
}
json.NewDecoder(r.Body).Decode(&req)
todo, err := h.Service.Create(req.Title)
if err != nil {
http.Error(w, err.Error(), 500)
return
}
json.NewEncoder(w).Encode(todo)
}
Because handlers only talk to the service (not the database), you can write different handlers for different routers — standard net/http, Gin, Chi — and they all call the exact same service methods underneath.
How It All Fits Together
Here's the flow when a request comes in:
HTTP Request → Handler → Service → Repository → Database
And back:
Database → Repository → Service → Handler → HTTP Response
Each layer only knows about the one directly below it. The handler doesn't know about the database. The service doesn't know about HTTP. The repository doesn't know about business logic. No bleed-through.
In practice, wiring it all together in main.go looks like this:
repo := repository.NewSQLiteRepo(db)
service := service.NewTodoService(repo)
handler := handler.NewStdHandler(service)
router := router.NewStdRouter(handler)
Want MongoDB instead? Change one line:
repo := repository.NewMongoRepo(collection)
Want Gin instead of net/http? Change one line:
handler := handler.NewGinHandler(service)
router := router.NewGinRouter(handler)
The service layer doesn't move. The repository interface doesn't move. Only the edges change.
This is the resulting file structure:
├── cmd
│ └── main.go
├── internal
│ └── handlers
│ └── models
│ └── repository
│ └── services
Wrapping Up
This structure might feel like extra work upfront compared to dumping everything in one file and it is. This is true only for small projects. But the moment you need to write a test, switch a database, or hand the project to someone else, you'll be glad you did it.
I built the full working example of this with a Todo List API — complete with SQLite and MongoDB repositories, and handlers for net/http, Gin, and Chi. Take a look if you want to see all the pieces in place.
Start with this structure even on small projects. It's a habit that pays off fast.
Top comments (0)