10 minutes
In Search of the Perfect Architecture For GoLang Applications
Ever since I started writing web applications, or more specifically REST APIs in Go, I’ve been looking for the perfect way to structure my code and files containing said code. I have “moved” to Go from PHP, where I was pretty comfortable with a sort of an MVC architecture, but when I made the move to Go I was lost. I’ve built a couple of simple applications before in Go, with little issue, but when I started designing the backend systems for Viberate.com I just couldn’t get comfortable. This was my first service based backend system written in Go. I had the base layout of the whole system thought out, but when it came to writing the code, where should I put what, how should I use it, what are the benefits and detriments of one approach versus the other were just some of my questions.
I’ve decided pretty early on that I want 3 basic types of layers throughout each service:
- Repository
- Service
- Handler
The Repository would handle persistence of the data to and from the database, the Service would contain all business logic, and the Handler would handle the communication between the web interface and the Service. But the next question arose, where to put them? Should I join them together in logical entities, and create a new package for each logical entity, like the Artist, Venue, or the user? Or should I stick each layer in their own package, regardless to which logical entity they belong to? I have probably went back and forth on those questions a dozen times. When I was finally happy with one way I would try to go forth with writing the code, but would quickly find something missing, or be impossible without some “hacky” workaround. Now, I can’t exactly tell you which way I chose in the end, without breaking my NDA, but let it suffice when I say, that I was not completely happy with it, but I accepted the flaws and drawbacks of my choice, and went on with developing the system to satisfy the requirement of the system being built in the first place.
Onion/Layered Architecture And Domain Driven Design
Even as I was joined by other team members, we continued developing our software on the “architecture” which I laid out, with minor alterations made to it throughout the time of development, I still wasn’t satisfied with it. I’ve read and re-read extensively on the subject on the web, coming across the onion and layered architectures, and with it, of course Domain-Driven-Design.
Since DDD is really a whole lot than just the architecture of the code, I will just skim it in this article, but I invite you to learn about it. This article will focus more on the Onion/Layered code architecture. While we can find some DDD concepts in the architectures domain layer it is important to point out that they are not the same thing, and we can use Onion without using DDD.
Imagine an onion, or if your imagination is not that good, you know, look at the picture above. An onion is comprised of different layers of its flesh, and just like the vegetable, your code in the Onion architecture is layered in the same way, like an onion. In the outermost layer, we have the Infrastructure layer, which holds code that interacts with the database i.e., then one layer lower, we find the Interface layer, which holds the interface code, like the HTTP handler. Peeling away one more layer we come to the Application layer, which is more of a passage, passing the request from the interface layer, to the next and final layer, the Domain layer, which holds all of the applications business logic.
It is important to note, that all communication between the layers is done only in one way, inward. At the center, the domain layer, does not know, nor does it need to know about any above layers. It provides logic that enables it to do its job, and interfaces for its dependencies, like the repositories.
I have also created a small example API with the Onion architecture, which can be found on gitlab. I advise you to visit it, as this example is used in this post from now on, for a small walkthrough. The example API is also available publicly on https://beer.tyr.lovrec.dev/api/v1/. Note that the database is rolled back every full hour.
Before we dive into the example, I must point out that this is my personal interpretation of the architecture, and is not necessarily 100% correct.
Now, let’s dive into the example.
Domain layer
Lets begin with the domain layer. We have the Beer entity struct defined
in the domain/beer.go
file, defining its properties and methods. Towards the
end the BeerRepo
interface is defined, which holds the Create
and Update
function signatures which the Beer entity uses to persist itself in the Save
method. The entity itself doesn’t care how it is persisted, be it in a
relational database, or a flat file on the file system, as long as the
repository implements the BeerRepo
interface.
The InitBeerRepo
function is used later in the container to inject the
repository that implements the BeerRepo
interface.
package domain
import "context"
type Beer struct {
tableName struct{} `pg:"beer"` // used by go-pg
ID int64 `json:"id" pg:",pk,notnull"`
Name string `json:"name" pg:",notnull,unique" validate:"required"`
Alcohol float64 `json:"alcohol" pg:",user_zero"`
ManufacturerID int64 `json:"manufacturer_id,omitempty" pg:",notnull" validate:"required"`
Manufacturer Manufacturer `json:"manufacturer,omitempty" pg:",notnull" validate:"structonly"`
Model // define created/updated at props and embed
}
func (b *Beer) Save(ctx context.Context) error {
if b.ID == 0 {
return beerRepoImpl.Create(ctx, b)
}
return beerRepoImpl.Update(ctx, b)
}
type BeerRepo interface {
Create(context.Context, *Beer) error
Update(context.Context, *Beer) error
}
var beerRepoImpl BeerRepo
func InitBeerRepo(impl BeerRepo) {
beerRepoImpl = impl
}
func GetBeerRepo() BeerRepo {
return beerRepoImpl
}
Infrastructure layer
In the infrastructure layer, we have the repository implementation that
satisfies the BeerRepo
interface of the domain layer in
infrastructure/database/beer.go
. In the Beer API example we interact only
with the database, using the go-pg library:
package database
import (
// ...
pgwrapper "gitlab.com/slax0rr/go-pg-wrapper"
)
type Beer struct {
db pgwrapper.DB
}
func NewBeer(db pgwrapper.DB) *Beer {
return &Beer{db}
}
func (p *Beer) Create(ctx context.Context, m *domain.Beer) error {
_, err := p.db.WithContext(ctx).Model(m).Returning("id").Insert()
if err != nil {
return err
}
return nil
}
func (p *Beer) Update(ctx context.Context, m *domain.Beer) error {
res, err := p.db.WithContext(ctx).Model(m).
Column("name", "alcohol", "manufacturer_id", "updated_at").
WherePK().
Update()
if err != nil {
return err
}
if res.RowsAffected() == 0 {
persErr := ErrRowNotFound
persErr.Details = struct{ ID int64 }{m.ID}
return persErr
}
return nil
}
The Create
and Update
methods do just that what their names suggest, create
and update beer records in the database. If the Update
method does produce any
affected rows it is assumed that the beer that the method was about to update
does not exist.
Interface layer
The interface layer holds the HTTP handler in
infrastructure/http/handlers/beer.go
, which handles incoming requests on the
beer resource by binding them to the structs or entities of the domain
layer, passing them on to the application layer, waiting for a response, and
writing said response back to http:
package handlers
import (
"net/http"
// ...
httphelper "gitlab.com/slax0rr/go-beer-api/interface/http"
)
type Beer struct {
beerApp application.Beer
http httphelper.HTTPHelper
}
func NewBeerHandler(
beerApp application.Beer,
http httphelper.HTTPHelper,
) *Beer {
return &Beer{beerApp, http}
}
func (h *Beer) Register(rtr *mux.Router) {
r := rtr.PathPrefix("/beer").Subrouter()
r.Path("").HandlerFunc(h.Create).Methods(http.MethodPost)
r.Path("/{id:[0-9]+}").HandlerFunc(h.Update).Methods(http.MethodPut)
}
func (h *Beer) Create(w http.ResponseWriter, req *http.Request) {
beer := new(domain.Beer)
if err := h.http.ParseRequest(w, req, beer); err != nil {
return
}
err := h.beerApp.Create(req.Context(), beer)
if err != nil {
logrus.WithError(err).Error("unable to create a new beer")
h.http.SendError(w, httphelper.ErrInternalServerError)
return
}
h.http.SendResponse(w, beer, http.StatusCreated)
}
func (h *Beer) Update(w http.ResponseWriter, req *http.Request) {
beer := new(domain.Beer)
if err := h.http.ParseRequest(w, req, beer); err != nil {
return
}
// an error can not occur, since the regex in the path already limits to positive numbers
beer.ID, _ = strconv.ParseInt(mux.Vars(req)["id"], 10, 64)
err := h.beerApp.Update(req.Context(), beer)
switch {
case err == nil:
h.http.SendResponse(w, beer, http.StatusOK)
case err.Error() == database.ErrRowNotFound.Error():
logrus.WithField("error", err).Info("requested beer not found")
h.http.SendError(w, httphelper.ErrNotFound)
return
default:
logrus.WithError(err).
WithField("id", beer.ID).
Error("unable to update the beer")
h.http.SendError(w, httphelper.ErrInternalServerError)
return
}
}
The above handler definition provides the Register
method, making it in effect
a handler that can be registered in the
app.go
handler registration loop, and the Create
and Update
methods which handle
the POST /beer
and PUT /beer/{id}
API calls.
The Create
method parses the request by binding the incoming JSON to the
domain.Beer
struct, passing on the newly created object to the application
layer, and writing the successfully created beer object back to the interface
or writing an error response to the interface in case of an error.
The httpJSONHelperImpl is used to decode and encode the request and response which can be quickly changed out for a different helper implementation if it would be needed. As a next step here, the correct http helper implementation should be chosen, based on the requests Content-Type header, but for the sake of the example this approach is fair enough.
Application layer
The application layer acts as link between the interface and the domain layer,
handling any application specific logic. The example defines a simple
application layer struct in application/beer.go
which receives the bound
beer entity object, that is forwarded to the domain layer by calling its own
Save
method.:
package application
import (
// ...
)
type Beer interface {
Create(context.Context, *domain.Beer) error
Update(context.Context, *domain.Beer) error
}
type BeerImpl struct{}
var _ Beer = new(BeerImpl)
func (a *BeerImpl) Create(ctx context.Context, beer *domain.Beer) error {
if beer == nil {
return fmt.Errorf("beer entity may not be nil")
}
return beer.Save(ctx)
}
func (a *BeerImpl) Update(ctx context.Context, beer *domain.Beer) error {
if beer == nil {
return fmt.Errorf("beer entity may not be nil")
}
return beer.Save(ctx)
}
Tying it all together
To help with initialising everything the infrastructure layer also provides a
sort of a container, which is basically a small collection of factories,
repository, and handler definitions which can be found in the
infrastructure/container
directory, split in three files:
- data_access.go - holds DB initialisation function and repository listing
- library.go - holds the factory for the HTTP response helper
- handlers.go - provides a list of handlers and injects them with the HTTP response helper
The app.go
file contains functions to start the web server, inject the
repository definitions into the domain layer, load handlers and register them
with the router. In all fairness, this part of the code belongs in the
infrastructure layer, and will probably be refactored there in the future.
And at last, there is the cli
directory, which holds the CLI commands to start
the server or create the database schema. And again, in all fairness, this part
belongs in the interface layer, and will also probably be refactored there in
the future.
I invite you to clone the repository provided above and try it out. The Makefile provided should guide you through to get the API successfully compiled and started.
Conclusion
Using the Onion architecture, makes it much more easier and apparent where some piece of code belongs, and keeps those pieces de-coupled, which will be a nightmare as your software grows. The example on gitlab extends a little bit beyond this post, implementing a bit more logic than described here, and it provides some solutions to the problems I was facing in the past or I am still facing them.
I know this is far from perfect and I’ve come to realise, I will never find the perfect or one-size-fits-all solution to all of my problems, but at the moment, I think this is the closest I’ve come so far. Of course implementing simple solutions like the example API, on such an architecture is beyond overkill, but it is used as an example only.
golang development architecture onion architecture layered architecture domain-driven-design
1971 Words
2020-04-07 17:19 +0000