One of the perks of working as a Software Developer at my current job is that I’ve gotten to see software development from a bunch of different angles. I’ve built stuff, shipped stuff, watched stuff break, and fixed it again. I’ve seen how infrastructure, while often behind the scenes, quietly shapes the cost and design of everything we build. But the part I’ve probably enjoyed the most? Learning how to structure large-scale backend systems. And that’s exactly what this piece is about.
So… What’s a “Layer”?
Let’s back up a bit.
Think of software like an onion. Not because it makes you cry (although… sometimes 👀), but because it has layers. Frontend, backend, database, infrastructure—all stacked together. But even within those broad categories, there are more layers hiding inside.
I used to think the backend was just APIs and a database. Request comes in, we fetch some stuff, maybe save some stuff, return a response—done. But over time, I realized there’s a better way to break things up. Just like the frontend has things like components or MVC (model-view-controller), the backend also benefits from a layered structure.
Here’s the version that’s stuck with me the most: API → Service → Repository.
Let’s peel each one open.
🛣️ API Layer
This is what talks directly to the outside world—your routes, your endpoints. It's where the frontend (or any external tool) makes a request. The API layer’s job is simple:
- Handle routing
- Authenticate the request
- Validate input
- Pass things along to the Service Layer
This layer should stay clean. No business logic here. Just making sure things are secure, valid, and sent to the right place.
🧠 Service Layer
This is where your app's brain lives.
All the business logic, the transformations, the “what should happen when X is true and Y is false” rules go here. You want to update a user’s profile picture? This is where the magic happens.
Need to call multiple repositories? Talk to an external service? Fire off an event? All that happens here.
And if you need to save or fetch anything, you call...
💾 Repository Layer
This layer handles all things database. No fancy logic. Just pure CRUD:
- Save data
- Fetch data
- Update data
- Delete data
Keeping it separate makes things easier to test and reuse. It’s like your backend’s personal librarian—knows where everything is, doesn’t mess with it.
📜 A Real-ish Example
Here’s what this looks like in a less structured setup. Imagine this all inside one giant API function:
func UpdateUsernameHandler(w http.ResponseWriter, r *http.Request) {
// Auth check
userID := r.Header.Get("X-User-ID")
if userID == "" {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
// Parse request
var req struct {
Username string `json:"username"`
}
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil || req.Username == "" {
http.Error(w, "Invalid request", http.StatusBadRequest)
return
}
// Update DB
db.Exec("UPDATE users SET username = ? WHERE id = ?", req.Username, userID)
// Return response
w.WriteHeader(http.StatusOK)
w.Write([]byte("Username updated"))
}
Now, this works, but imagine repeating that for every route. It becomes this spaghetti mess of auth checks, validation, business logic, and DB stuff all mashed together.
🧼 Cleaner Version (With Layers)
Let’s split it up.
API Layer:
func UpdateUsernameHandler(w http.ResponseWriter, r *http.Request) {
userID := GetUserIDFromHeader(r)
if userID == "" {
respondUnauthorized(w)
return
}
req, err := ParseAndValidateUsernameRequest(r)
if err != nil {
respondBadRequest(w, err)
return
}
err = userService.UpdateUsername(userID, req.Username)
if err != nil {
respondServerError(w, err)
return
}
respondSuccess(w, "Username updated")
}
Service Layer:
func (s *UserService) UpdateUsername(userID, newUsername string) error {
if len(newUsername) < 3 {
return errors.New("username too short")
}
// Optional: check if username already taken
exists, _ := s.repo.DoesUsernameExist(newUsername)
if exists {
return errors.New("username already exists")
}
return s.repo.UpdateUsername(userID, newUsername)
}
Repository Layer:
func (r *UserRepo) UpdateUsername(userID, newUsername string) error {
_, err := r.db.Exec("UPDATE users SET username = ? WHERE id = ?", newUsername, userID)
return err
}
func (r *UserRepo) DoesUsernameExist(username string) (bool, error) {
var count int
err := r.db.QueryRow("SELECT COUNT(*) FROM users WHERE username = ?", username).Scan(&count)
return count > 0, err
}
Why Bother?
This way of writing code is called Separation of Concerns, and once it clicks, it’s hard to go back.
Breaking things into layers helps you:
- Reuse code (validation, auth, DB access)
- Write smaller, focused functions
- Test easily (mock a service, test a repo)
- Onboard teammates without overwhelming them
- Debug faster
Each layer has its own job. No one’s stepping on anyone else’s toes. You can evolve and scale your app without turning it into an unmaintainable nightmare.
Final Thoughts
This isn’t some strict rule. But once you start building bigger apps with more moving parts, splitting your code into layers will save your sanity. Just like onions add flavour to food (and tears to your eyes), backend layers add structure to your code (and prevent future crying during refactors 😅).
If you’ve ever struggled to keep things clean in your backend or found your functions growing out of control, give layering a shot.
Your future self will thank you.
Top comments (0)