The ‘fat service’ pattern for Go web applications
In this post I'd like to talk about one of my favorite architectural patterns for building web applications and APIs in Go. It's kind of a mix between the service object and fat model patterns — so I mentally refer to it as the 'fat service' pattern, but it might have a more formal name that I'm not aware of 🙃
It's certainly not a perfect pattern (we'll discuss some of the pros and cons later) — but it is (deliberately) simple, pragmatic, and I find it often works well for small-to-medium sized projects.
At a high-level, the fat service pattern splits your project code into two distinct 'layers':
The application layer. This contains your code related to reading and writing HTTP requests and responses, authenticating/authorizing requests, session management, etc.
The service layer. This contains your business logic, defines your core data types, and is also responsible for interacting with any persistent data stores.
A fat service example
Let's illustrate how this pattern works with an example of a JSON API.
Specifically, let's say that we want to build an API with a POST /register
endpoint which is used to register a new user. When a client makes a request to this endpoint, let's pretend we want to take the following actions:
- Parse the JSON input into a Go struct so we can work with it easily.
- Carry out some validation checks on the data (and return an error response to the client if any of them fail).
- Create a hash of the new user's password.
- Insert a record for the user into a database.
- Send a notification to a Slack channel to say that a new user has registered.
- Return a
204 No Content
response to the client if everything worked successfully.
Using the fat service pattern, we could structure our project so that the directory and file layout looks like this:
The cmd/api
package will contain the application layer code, and the internal/service
package will contain the service layer code.
Then, very roughly, the code in our service layer might look something like this:
And the code in our application layer might look like this (I've omitted the helper functions for brevity):
Hopefully you get the rough idea.
Essentially, our service layer contains a Service.RegisterUser()
method which executes all the validation checks, business logic and SQL queries related to registering a user.
The expected input to this method is the simple, standard, service.RegisterUserInput
Go struct.
And in our application layer's registerUserHandler()
handler we can decode the JSON request body directly into that struct and pass it on the service layer, handling any returned errors as necessary.
The pros and cons
In terms of benefits, there are quite a lot of nice things about this pattern:
It's fairly simple. The number of mental hoops to jump through when reading the code is relatively low. You don't have to dig through lots of packages or abstractions to follow what the code is doing — meaning it's relatively easy for newcomers to your project to understand (or even yourself after a long break).
The separation of concerns keeps our
registerUserHandler()
code primarily focused on reading and writing HTTP requests and responses.The code in the service layer can be reused by other applications. For example, we could create a CLI application under
cmd/cli
with a task that also calls theService.RegisterUser()
method.This one is more personal, but I find it easier to reason about my business logic and write the code for it when the input is a well-defined Go struct with the correct types (rather than a more 'messy' input like a JSON string or HTML-encoded form data).
It's really practical for APIs and web applications. You can parse JSON or HTML form data from a request body directly into the
service.RegisterUserInput
struct in your handlers, and then pass that struct to the service layer for processing. You don't need to create interim types in your handlers to hold the decoded request data, or copy data from one struct to another.Methods in the service layer can potentially return validation errors from multiple points in the code, and you can deal with them all just once in your handler. For example, if our user
INSERT
failed because we tried to insert a record with a duplicate username, then we could return a "username is already taken" validation error from our service layer in addition to the pre-INSERT
validation checks.Working with database transactions is easy. If we wanted to execute multiple SQL statements as part of registering a user in a single transaction, we could initialize the
sql.TX
, execute all the necessary statements, and commit the transaction all within ourService.RegisterUser()
method. We don't need to pass thesql.TX
around to a bunch of different places in our codebase.If you want to test only your application layer logic only, this pattern lends itself nicely to creating an interface type that describes the methods on the
service.Service
struct, which you can then satisfy with a mock implementation.
But it's not perfect, and there are also a few downsides:
When you are looking at the code for your handlers, you can't immediately see what the expected inputs are. You have to navigate to the
service
package and look at the fields of theservice.RegisterUserInput
struct. With most modern text editors this is just one click away, but it still introduces a bit of 'obscurity' and feels less than ideal to me.Not having a separate abstraction for the database logic makes it harder to swap out one database for another in the future (say moving from SQLite to PostgreSQL).
You can't easily mock the database calls during tests. Personally I tend to prefer using a test instance of an actual database for testing, so I don't find this too much of a drawback most of the time. But if you need to mock the database (i.e. to speed up test runtime, or because it's a hard requirement from a client) then this pattern doesn't really suit that.
Lastly, SQL queries which use
database/sql
and theQuery()
method to return multiple rows of data are quite verbose. These queries can take up a lot of visual space and add clutter to the service layer methods — which ultimately starts to reduce the scannability of the code. Usingjmoiron/sqlx
orblockloop/scan
can be a big help here.
But overall I like this pattern. I've used it a lot over the past 3-4 years and have found it pragmatic and practical for small-to-medium sized projects. So long as you don't need to mock your database calls, you might want to consider it as an alternative to the more common 2-layer handler-repository
pattern (where business logic is in your handlers), or a simpler alternative to a more complicated 3-layer handler-service-repository
pattern.