DEV Community

Cover image for Where Does This Code Go? Layers in Go
Francis Awuor
Francis Awuor

Posted on

Where Does This Code Go? Layers in Go

Until not too long ago, when I wanted to change one thing in my code, I'd end up having to touch a bunch of other things. Or I'd be staring at a function trying to figure out why it's doing three completely different jobs.

Turns out there's a name for what I was doing wrong, and a pattern that fixes it.

This article is essentially what I wish someone had shown me earlier. We're going to walk through a way of organising your Go code into three layers: Service, Repository, and Delivery…

So that each part of your code has one job, and changing one thing doesn't ripple into everything else.

I'll be using two of my own projects as examples: a netcat-based group chat app, and a URL shortener.


What is Separation of Concerns?

Before we get into the layers, it's worth naming the idea behind all of this.

Separation of concerns basically means: each part of your code should have one clear job, and should know as little as possible about everything else. That's it.

The reason this matters is that when one part of your code knows too much about another part, they get tangled. And tangled code is code that's hard to change. You touch one thing and break another.

The three-layer pattern we're about to walk through is one concrete way to apply this idea in a Go backend. Each layer gets one job. Each layer talks to the next one through a clean boundary. That boundary is what keeps things from bleeding into each other.


The Three Layers

Here's the model we need to internalize before we get into the details:

Service - this is what your app does. Your business rules. The reason the app exists. If someone asked "what does this app actually do?", the answer lives here.

Repository - this is where your app stores and retrieves data. It doesn't care about business rules. It just reads and writes.

Delivery - this is how users interact with your app. An HTTP server, a CLI, a netcat listener. It receives input, calls the service, and returns a response. It's the outermost layer, the plumbing that connects your app to the outside world.

Each layer only talks to the one below it. Delivery calls the service. The service calls the repository. None of them reach past their neighbour, and that constraint is exactly what keeps your code clean.

We'll start with the most important one: the service layer.


The Service Layer

The service layer is the core of your app. It's where your business rules live (the logic that makes your app what it is, not just plumbing or storage.)

A good way to think about it: if someone asked "what does this app actually do?", the service layer is where you'd point them.

Here's what my group chat service handles:

  • Registering clients when they connect
  • Creating groups and letting clients join them
  • Broadcasting messages to everyone in a group
  • Unregistering clients when they disconnect
  • Keeping a chat history per group, and sending it to new clients when they join
  • Notifying everyone when someone joins or leaves

All of that is business logic. Those are decisions the app makes. Not the database, not the HTTP handler.

Here's the public surface of that service:

func (s *Service) Register(client *Client) error
func (s *Service) Unregister(client *Client)
func (s *Service) Broadcast(sender *Client, content string)
func (s *Service) Rename(client *Client, newName string) error
func (s *Service) JoinGroup(client *Client, groupName string) error
Enter fullscreen mode Exit fullscreen mode

Notice what's missing. There's no mention of netcat, no mention of how the client is connected, no mention of how messages are being sent over the wire. The service just does the work. How users are actually reaching the app is someone else's problem, i.e., the delivery layer's problem, which we'll get to.

One thing worth pointing out about this particular service: it uses channels and a Run() loop internally for concurrency safety, which is pretty idiomatic Go for this kind of problem. That's an implementation detail though. The outside world just calls Register, Broadcast, JoinGroup and so on. It has no idea what's happening inside.

That's the point. The service's internals are its own business.


The Repository Layer

The repository layer handles storage. Reading, writing, that's it. The service doesn't care how data is stored. It just calls clean, intention-revealing methods and expects results back.

Here's the Store interface from the URL shortener:

type Store interface {
    Save(link ShortLink) error
    Get(id string) (ShortLink, error)
    IncrementHits(id string) error
}
}
Enter fullscreen mode Exit fullscreen mode

The service calls Save. The service calls Get. It has no idea whether that's hitting a Postgres database or an in-memory map. That's the whole point of defining it as an interface.

And here's what makes that useful in practice. I actually have two implementations of this interface. An in-memory one:

func (store *MemStore) Save(link ShortLink) error { ... }
func (store *MemStore) Get(id string) (ShortLink, error) { ... }
func (store *MemStore) IncrementHits(id string) error { ... }
Enter fullscreen mode Exit fullscreen mode

And a Postgres one:

func (store *PGStore) Save(link ShortLink) error { ... }
func (store *PGStore) Get(id string) (ShortLink, error) { ... }
func (store *PGStore) IncrementHits(id string) error { ... }
Enter fullscreen mode Exit fullscreen mode

Both implement the same interface. Both are completely valid stores as far as the service is concerned. The service just calls Save. It doesn't know and doesn't care which one it's talking to. So you can easily swap one for another, or even swap in a mock version for tests.

We'll see how you actually swap between them in a minute. But first, the delivery layer.


The Delivery Layer

The delivery layer is the outermost layer. It's how users actually reach your app, e.g., via an HTTP server, a CLI, a netcat listener. Its job is simple: receive input, call the service, return a response.

That's it. It should contain no business logic.

A useful test for this: if you replaced your HTTP server with a CLI tomorrow, would you need to rewrite this code? If yes, that logic belongs in the service, not here.

Here's what a handler in my URL shortener looks like:

func (h *Handler) HandleShorten(w http.ResponseWriter, r *http.Request) {
    var req ShortenRequest
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "invalid request", http.StatusBadRequest)
        return
    }

    link, err := h.shortener.Shorten(req.URL)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(link)
}
Enter fullscreen mode Exit fullscreen mode

The handler decodes the request, calls h.shortener.Shorten(), and writes the response. That's all it does. It has no idea how the shortener generates a short code, which store it's using, or any of the logic involved. It just calls the service and handles the result.

Things the delivery layer should not be doing:

  • Generating short codes
  • Validating business rules (e.g., checking if a URL already exists)
  • Knowing what database you're using
  • Knowing how the service implements anything internally

If you catch yourself writing that kind of logic in a handler, it's a sign it belongs one layer down.


Interfaces: The Glue That Makes It Work

We've mentioned interfaces a couple of times now. Let's actually talk about why they matter here.

In Go, an interface defines what something can do, without caring about what it is. And that distinction is what makes the whole swappability idea practical rather than theoretical.

Here's the Store interface again:

type Store interface {
    Save(link ShortLink) error
    Get(id string) (ShortLink, error)
    IncrementHits(id string) error
}
Enter fullscreen mode Exit fullscreen mode

The service depends on this interface. Not on MemStore. Not on PGStore. It just knows it's talking to something that can Save, Get, and IncrementHits. What that something actually is, is decided elsewhere.

Here's what the shortener (service) constructor looks like:

func NewShortener(store Store, generator IDGenerator) *Shortener {
    return &Shortener{store: store, generator: generator}
}
Enter fullscreen mode Exit fullscreen mode

It takes a Store. Not a *MemStore. Not a *PGStore. A Store. So you can pass in either, and it'll work exactly the same.

This is sometimes called programming to an interface rather than an implementation. The service doesn't care what's behind the interface, and that's precisely why you can swap things out without touching the service at all.


Encapsulation: A Goal, Not a Side Effect

There's another benefit to this pattern that's worth naming explicitly, because it's easy to miss.

When each layer only exposes a clean interface to the layer above it, the internals of each layer are hidden. And that's not just a nice accident. I’d say it’s something we should be actively aiming for.

Here's why it matters practically: if your service hides its internals, you can refactor it freely. Change how you generate short codes, swap out your concurrency strategy, restructure your internal logic, etc. None of that affects the delivery layer, because the delivery layer only ever saw the interface. It never knew what was inside.

Same goes for the repository. You could migrate from Postgres to SQLite, and as long as your new implementation satisfies the Store interface, nothing else in your codebase needs to change.

Your teammates (or future you), can work on one layer without needing to understand or touch the others. I think that's a genuinely good thing to have, even on small projects.


Putting It All Together

Here's where it all clicks. Look at the main.go from the URL shortener:

// store := shorten.NewMemStore()
store := shorten.NewPGStore(sqlDB)
generator := shorten.NewBase62Generator()
shortener := shorten.NewShortener(store, generator)

mux := http.NewServeMux()
shorten.RegisterRoutes(mux, shortener)

server := http.Server{
    Addr:    ":8080",
    Handler: mux,
}
Enter fullscreen mode Exit fullscreen mode

This is the wiring. main.go is where all three layers meet (and it's really the only place they meet.)

Notice the commented out line at the top. Switching from in-memory storage to Postgres is literally a one line change. That's the payoff of having defined a Store interface and having both implementations satisfy it.

The flow is:

  • main creates a store and passes it to the service
  • main creates the service and passes it to the delivery layer
  • The delivery layer handles requests and calls the service
  • The service does the work and calls the repository
  • Nobody reaches past their neighbour

Each layer was built independently. Each layer can be changed independently. And main is just the place that plugs them all together.


Conclusion

So to recap. Three layers, three jobs:

  • Service: what your app does
  • Repository: where your app stores data
  • Delivery: how users reach your app

Each layer talks to the one below it through a clean boundary. None of them know more than they need to. That's separation of concerns in practice.

The payoff isn't always obvious on a small project. But even on small projects, I've found it makes the code easier to reason about, easier to change, and easier for someone else to pick up. You always know where something belongs. And when you need to change something, you know exactly where to go.

It's one of those things that feels like a bit of extra work upfront, but saves you a lot of pain later.

Top comments (0)