DEV Community

Cover image for Clean Architecture, the right way
Angad Sharma
Angad Sharma

Posted on • Originally published at Medium on

20 5

Clean Architecture, the right way

A practical guide to Clean Architecture, with a personal touch.

Just last Sunday, I was randomly browsing GitHub, (like most of my Sundays usually go) and I stumbled upon a very popular repository, with over 10K commits. Now I am not going to name which project it was but it should suffice to say that even though I knew the stack of the project, the code itself was completely alien to me. Some features were randomly thrown adrift a sea of loosely cohesive functions inside directories called “utils” or worse, “helpers”.

The catch with big projects is that overtime, they become so complex that it is actually cheaper to re-write them rather than training new talent to actually understand the code and then contribute.

This brings me to the ulterior motive of the rather practical anecdote, which is to talk about Clean Architecture, at an implementation level. Now this blog is going to contain some Go code, but fret not, even if you are not familiar with the beautiful language, the concepts are fairly easy to grok.

What is so Clean about Clean Architecture?

In short, you get the following benefits from using Clean Architecture:

  • Database Agnostic : Your core business logic does not care if you are using Postgres, MongoDB, or Neo4J for that matter.
  • Client Interface Agnostic: The core business logic does not care if you are using a CLI, a REST API, or even gRPC.
  • Framework Agnostic: Using vanilla nodeJS, express, fastify? Your core business logic does not care about that either.

Now if you want to read more about how clean architecture works, you can read the fantastic blog, The Clean Architecture, by Uncle Bob. For now, lets jump to the implementation. To follow along, view the repository here.



Clean-Architecture-Sample
├── api
│ ├── handler
│ │ ├── admin.go
│ │ └── user.go
│ ├── main.go
│ ├── middleware
│ │ ├── auth.go
│ │ └── cors.go
│ └── views
│ └── errors.go
├── bin
│ └── main
├── config.json
├── docker-compose.yml
├── go.mod
├── go.sum
├── Makefile
├── pkg
│ ├── admin
│ │ ├── entity.go
│ │ ├── postgres.go
│ │ ├── repository.go
│ │ └── service.go
│ ├── errors.go
│ └── user
│ ├── entity.go
│ ├── postgres.go
│ ├── repository.go
│ └── service.go
├── README.md


Enter fullscreen mode Exit fullscreen mode

Entities

Entities are the core business objects that can be realized by functions. In MVC terms, they are the model layer of the clean architecture. All entities and services are enclosed in a directory called pkg. This is actually what we want to abstract away from the rest of the application.

If you take a look at entity.go for user, it looks like this:

package user
import "github.com/jinzhu/gorm"
type User struct {
gorm.Model
FirstName string `json:"first_name,omitempty"`
LastName string `json:"last_name,omitempty"`
Password string `json:"password,omitempty"`
PhoneNumber string `json:"phone_number,omitempty"`
Email string `json:"email,omitempty"`
Address string `json:"address,omitempty"`
DisplayPic string `json:"display_pic,omitempty"`
}
view raw entity.go hosted with ❤ by GitHub

Entities are used in the Repository i_nterface, which can be implemented for any database. In this case we have implemented it for Postgre, in _postgres.go. Since repositories can be realized for any database, therefore they are independent of all of their implementation details.

package user
import (
"context"
)
type Repository interface {
FindByID(ctx context.Context, id uint) (*User, error)
BuildProfile(ctx context.Context, user *User) (*User, error)
CreateMinimal(ctx context.Context, email, password, phoneNumber string) (*User, error)
FindByEmailAndPassword(ctx context.Context, email, password string) (*User, error)
FindByEmail(ctx context.Context, email string) (*User, error)
DoesEmailExist(ctx context.Context, email string) (bool, error)
ChangePassword(ctx context.Context, email, password string) error
}
view raw repository.go hosted with ❤ by GitHub

Services

Services include interfaces for higher level business logic oriented functions. For example, FindByID, might be a repository function, but login or signup are service functions. Services are a layer of abstraction over repositories by the fact that they do not interact with the database, rather they interact with the repository interface.

package user
import (
"context"
"crypto/md5"
"encoding/hex"
"errors"
)
type Service interface {
Register(ctx context.Context, email, password, phoneNumber string) (*User, error)
Login(ctx context.Context, email, password string) (*User, error)
ChangePassword(ctx context.Context, email, password string) error
BuildProfile(ctx context.Context, user *User) (*User, error)
GetUserProfile(ctx context.Context, email string) (*User, error)
IsValid(user *User) (bool, error)
GetRepo() Repository
}
type service struct {
repo Repository
}
func NewService(r Repository) Service {
return &service{
repo: r,
}
}
func (s *service) Register(ctx context.Context, email, password, phoneNumber string) (u *User, err error) {
exists, err := s.repo.DoesEmailExist(ctx, email)
if err != nil {
return nil, err
}
if exists {
return nil, errors.New("User already exists")
}
hasher := md5.New()
hasher.Write([]byte(password))
return s.repo.CreateMinimal(ctx, email, hex.EncodeToString(hasher.Sum(nil)), phoneNumber)
}
func (s *service) Login(ctx context.Context, email, password string) (u *User, err error) {
hasher := md5.New()
hasher.Write([]byte(password))
return s.repo.FindByEmailAndPassword(ctx, email, hex.EncodeToString(hasher.Sum(nil)))
}
func (s *service) ChangePassword(ctx context.Context, email, password string) (err error) {
hasher := md5.New()
hasher.Write([]byte(password))
return s.repo.ChangePassword(ctx, email, hex.EncodeToString(hasher.Sum(nil)))
}
func (s *service) BuildProfile(ctx context.Context, user *User) (u *User, err error) {
return s.repo.BuildProfile(ctx, user)
}
func (s *service) GetUserProfile(ctx context.Context, email string) (u *User, err error) {
return s.repo.FindByEmail(ctx, email)
}
func (s *service) IsValid(user *User) (ok bool, err error) {
return ok, err
}
func (s *service) GetRepo() Repository {
return s.repo
}
view raw service.go hosted with ❤ by GitHub

Services are implemented at the user interface level.

Interface Adapters

Each user interface has it’s separate directory. In our case, since we have an API as an interface, we have a directory called api.

Now since each user-interface listens to requests differently, interface adapters have their own main.go files, which are tasked with the following:

  • Creating Repositories
  • Wrapping Repositories inside Services
  • Wrap Services inside Handlers

Here, Handlers are simply user-interface level implementation of the Request-Response model. Each service has its own Handler. See user.go

package handler
import (
"encoding/json"
"net/http"
"github.com/L04DB4L4NC3R/jobs-mhrd/api/middleware"
"github.com/L04DB4L4NC3R/jobs-mhrd/api/views"
"github.com/L04DB4L4NC3R/jobs-mhrd/pkg/user"
"github.com/dgrijalva/jwt-go"
"github.com/spf13/viper"
)
func register(svc user.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
views.Wrap(views.ErrMethodNotAllowed, w)
return
}
var user user.User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
views.Wrap(err, w)
return
}
u, err := svc.Register(r.Context(), user.Email, user.Password, user.PhoneNumber)
if err != nil {
views.Wrap(err, w)
return
}
w.WriteHeader(http.StatusCreated)
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"email": u.Email,
"id": u.ID,
"role": "user",
})
tokenString, err := token.SignedString([]byte(viper.GetString("jwt_secret")))
if err != nil {
views.Wrap(err, w)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"token": tokenString,
"user": u,
})
return
})
}
func login(svc user.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
views.Wrap(views.ErrMethodNotAllowed, w)
return
}
var user user.User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
views.Wrap(err, w)
return
}
u, err := svc.Login(r.Context(), user.Email, user.Password)
if err != nil {
views.Wrap(err, w)
return
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"email": u.Email,
"id": u.ID,
"role": "user",
})
tokenString, err := token.SignedString([]byte(viper.GetString("jwt_secret")))
if err != nil {
views.Wrap(err, w)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"token": tokenString,
"user": u,
})
return
})
}
func profile(svc user.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// @protected
// @description build profile
if r.Method == http.MethodPost {
var user user.User
if err := json.NewDecoder(r.Body).Decode(&user); err != nil {
views.Wrap(err, w)
return
}
claims, err := middleware.ValidateAndGetClaims(r.Context(), "user")
if err != nil {
views.Wrap(err, w)
return
}
user.Email = claims["email"].(string)
u, err := svc.BuildProfile(r.Context(), &user)
if err != nil {
views.Wrap(err, w)
return
}
json.NewEncoder(w).Encode(u)
return
} else if r.Method == http.MethodGet {
// @description view profile
claims, err := middleware.ValidateAndGetClaims(r.Context(), "user")
if err != nil {
views.Wrap(err, w)
return
}
u, err := svc.GetUserProfile(r.Context(), claims["email"].(string))
if err != nil {
views.Wrap(err, w)
return
}
json.NewEncoder(w).Encode(map[string]interface{}{
"message": "User profile",
"data": u,
})
return
} else {
views.Wrap(views.ErrMethodNotAllowed, w)
return
}
})
}
func changePassword(svc user.Service) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
var u user.User
if err := json.NewDecoder(r.Body).Decode(&u); err != nil {
views.Wrap(err, w)
return
}
claims, err := middleware.ValidateAndGetClaims(r.Context(), "user")
if err != nil {
views.Wrap(err, w)
return
}
if err := svc.ChangePassword(r.Context(), claims["email"].(string), u.Password); err != nil {
views.Wrap(err, w)
return
}
return
} else {
views.Wrap(views.ErrMethodNotAllowed, w)
return
}
})
}
// expose handlers
func MakeUserHandler(r *http.ServeMux, svc user.Service) {
r.Handle("/api/v1/user/ping", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
return
}))
r.Handle("/api/v1/user/register", register(svc))
r.Handle("/api/v1/user/login", login(svc))
r.Handle("/api/v1/user/profile", middleware.Validate(profile(svc)))
r.Handle("/api/v1/user/pwd", middleware.Validate(changePassword(svc)))
}
view raw user.go hosted with ❤ by GitHub

Error Handling

Error flow in Clean Architecture

The basic principle behind error handling in Clean Architecture is the following:

Repository errors should be uniform and should be wrapped and implemented differently for each interface adapter.

What this essentially means is that all of the database level errors should be handled by the user interfaces differently. For example, if the user interface in question is a REST API then errors should be manifested in the form of HTTP status codes, in this case, code 500. Whereas if it is a CLI then it should exit with status code 1.

In Clean Architecture, Repository errors can be at the root of pkg so that Repository functions is able to call them in case of a control flow miscarriage, as seen below:

package errors
import (
"errors"
)
var (
ErrNotFound = errors.New("Error: Document not found")
ErrNoContent = errors.New("Error: Document not found")
ErrInvalidSlug = errors.New("Error: Invalid slug")
ErrExists = errors.New("Error: Document already exists")
ErrDatabase = errors.New("Error: Database error")
ErrUnauthorized = errors.New("Error: You are not allowed to perform this action")
ErrForbidden = errors.New("Error: Access to this resource is forbidden")
)
view raw errors.go hosted with ❤ by GitHub

The same errors can then be implemented according to the specific user interface, and can most often be wrapped in views, at the Handler level, as seen below:

package views
import (
"encoding/json"
"errors"
"net/http"
log "github.com/sirupsen/logrus"
pkg "github.com/L04DB4L4NC3R/jobs-mhrd/pkg"
)
type ErrView struct {
Message string `json:"message"`
Status int `json:"status"`
}
var (
ErrMethodNotAllowed = errors.New("Error: Method is not allowed")
ErrInvalidToken = errors.New("Error: Invalid Authorization token")
ErrUserExists = errors.New("User already exists")
)
var ErrHTTPStatusMap = map[string]int{
pkg.ErrNotFound.Error(): http.StatusNotFound,
pkg.ErrInvalidSlug.Error(): http.StatusBadRequest,
pkg.ErrExists.Error(): http.StatusConflict,
pkg.ErrNoContent.Error(): http.StatusNotFound,
pkg.ErrDatabase.Error(): http.StatusInternalServerError,
pkg.ErrUnauthorized.Error(): http.StatusUnauthorized,
pkg.ErrForbidden.Error(): http.StatusForbidden,
ErrMethodNotAllowed.Error(): http.StatusMethodNotAllowed,
ErrInvalidToken.Error(): http.StatusBadRequest,
ErrUserExists.Error(): http.StatusConflict,
}
func Wrap(err error, w http.ResponseWriter) {
msg := err.Error()
code := ErrHTTPStatusMap[msg]
// If error code is not found
// like a default case
if code == 0 {
code = http.StatusInternalServerError
}
w.WriteHeader(code)
errView := ErrView{
Message: msg,
Status: code,
}
log.WithFields(log.Fields{
"message": msg,
"code": code,
}).Error("Error occurred")
json.NewEncoder(w).Encode(errView)
}
view raw errors.go hosted with ❤ by GitHub

Each Repository level error, or otherwise, is wrapped inside a map, which returns an HTTP status code corresponding to the appropriate error.

Conclusion

Clean Architecture is a great way to structure your code and then forget about all of the complexities that might arise due to agile iterations or rapid prototyping. Being database, user interface, as well as framework independent, Clean Architecture clearly takes the cake for living up to its name.

References


This Article was originally published on Medium under Developer Student Clubs VIT, Powered By Google Developers. Follow us on Medium.


Heroku

Simplify your DevOps and maximize your time.

Since 2007, Heroku has been the go-to platform for developers as it monitors uptime, performance, and infrastructure concerns, allowing you to focus on writing code.

Learn More

Top comments (1)

Collapse
 
alexmenor profile image
Alex Menor

I was looking for an article like this after reading clean architecture so I could see an example in Go.

Great job

A Workflow Copilot. Tailored to You.

Pieces.app image

Our desktop app, with its intelligent copilot, streamlines coding by generating snippets, extracting code from screenshots, and accelerating problem-solving.

Read the docs

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay