The Backend Onion š§


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 realised 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.
Subscribe to my newsletter
Read articles from m13ha directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by

m13ha
m13ha
Welcome to my little corner of the internet where business savvy meets coding prowess, all wrapped up in the day-to-day adventures of yours truly. Whether you're here to level up your programming skills, gain some business insights, or just enjoy a slice of life through my eyes, you're in the right place! #CodeLife #BusinessMinded #TechEntrepreneur #ProgrammingAdventures #StartupJourney #DeveloperDiaries #LifeInCode #BusinessAndBytes #TechLifeBalance #CodeEntrepreneur