The right way to clean architecture

The right way to clean architecture

Just last Sunday, I was wandering around on GitHub (like most of my Sundays) and stumbled upon a very popular repository with more than 10K submissions. I don't plan to name it. Although I know the technology stack of this project, I am not familiar with the code yet. Many of these features are randomly thrown in a helpers directory called utils or worse.

The trap of large projects is that over time, they can become so complex that rewriting is much easier than training new people to understand the code and then modify it.

This brings me to the realization of a clean architecture. This article will contain some Go code, but don't worry, even if you are not familiar with the language, the concepts to be said are fairly easy to understand.

What is a clean architecture?

The right way to clean architecture

In short, you will get the following benefits from using a clean architecture.

  • Database independence: The core business logic does not care about whether to use Postgres, MongoDB or Neo4J.
  • Client interface independence: The core business logic does not care whether you use CLI, REST API, or even gRPC.
  • Framework independence: use vanilla nodeJS, express, fastify? Your core business logic does not care about these.

Now, if you want to learn more about how clean architecture works, you can read Uncle Bob's blog (2). Now, let's start a sample implementation of a clean architecture, available on GitHub (1).

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

entity

Entities are core business objects that can be implemented through functions. In MVC terms, they are the model layers of a clean architecture. All entities and services are packaged in the pkg directory. This is actually what we want to abstract and separate it from other parts.

If you look at entity.go under 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"`
}

pkg/user/entity.go

The entity is used in the Repository interface, and it can be implemented with any database. In this example, we implemented it with Postgres in postgres.go. Since Repository can be implemented with any database, it has nothing to do with the 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
}
pkg/user/repository.go

Service

Services include interfaces for higher-level business logic functions. For example, FindByID may be a storage layer function, but login or signup is a service layer function. Services are the abstraction layer of storage. They do not interact with the database, but with the storage 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
}
pkg/user/service.go

Services are implemented at the user interface level.

Interface adapter

Each user interface has a separate directory. In our example, because we use API as the interface, there is a directory called api.

Now, since each user interface monitors requests in a different way, the interface adapter has its own main.go file, and its tasks are as follows.

  • Create Repository
  • The package repository in the service
  • Package service in Handler

Here, the Handler program is just the user interface implementation of the Request-Response model. Each service has its own Handler program. 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)))
}

Error handling

The right way to clean architecture

The basic principles of error handling in a clean architecture are as follows.

Warehouse-level errors should be unified, and for each interface adapter, it should be encapsulated and implemented in different ways.

This essentially means that all database-level errors should be handled in different ways by the user interface. For example, if the user interface is a REST API, the error should be expressed in the form of an HTTP status code, such as a 500 error. If it is CLI mode, it should exit with status code 1.

In a clean architecture, Repository errors can be in the root directory of pkg, so that Repository functions can call them when there is a problem with the control flow, as shown in the following figure.

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")
)

pkg/errors.go

Then, the same error can be implemented according to the specific user interface. The most common one can be encapsulated in the view at the Handler level, as shown in the following figure.


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)
}

Each Repository-level error, or other errors, will be encapsulated in a map, which returns an HTTP status code corresponding to the corresponding error.

to sum up

Clean architecture is a good way to structure code, and can forget all the complicated problems that may arise due to agile iteration or rapid prototyping. Since it has nothing to do with databases, user interfaces, and frameworks, a clean architecture is indeed worthy of the name.

(Editor’s note: After reading this article, if you still have some doubts, it is recommended to read the link 1 project code and then read it in conjunction with the article)

Reference

(1) Clean Architecture Sample
https://github.com/L04DB4L4NC3R/clean-architecture-sample
(2) Clean Coder Blog
https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html

Original English:
https://medium.com/gdg-vit/clean-architecture-the-right-way-d83b81ecac6

Reference reading:

  • Several rules of code review
  • Exploring the solution for complex timing tasks: distributed task scheduling system
  • In-depth interpretation of the principles and applications of HTTP3
  • 6 lines of code interpretation of Bitcoin halving
  • How to write a concise CQRS code?

This article is a translation of high-availability architecture, technical originality and architecture practice articles, and you are welcome to submit articles through the official account menu "Contact Us".

Highly available architecture

Changing the way the internet is built

The right way to clean architecture

Guess you like

Origin blog.51cto.com/14977574/2546135