• Clean Architecture in Go: An example of Clean Architecture in Go using gRPC
  • Original author: Yusuke Hatanaka
  • The Nuggets translation Project
  • Permanent link to this article: github.com/xitu/gold-m…
  • Translator: yuwhuawang
  • Proofread: github.com/lihanxiang, tmpbook

An example of a clean architecture for the Go project using gRPC

What I’m trying to tell you is

Clean architecture is a very well-known architecture nowadays. However, we may not know much about the implementation details. So I tried to create a Go project using gRPC with a clean architecture.

  • hatajoe/8am: Contribute to hatajoe/8am development by creating an account on GitHub.

This small project is an example of user registration. Feel free to comment below.

structure

8AM is based on a clean architecture and the project structure is as follows.

% tree. The ├ ─ ─ a Makefile ├ ─ ─ the README. Md ├ ─ ─ app │ ├ ─ ─ domain │ │ ├ ─ ─ model │ │ ├ ─ ─ the repository │ │ └ ─ ─ service │ ├ ─ ─ Interface │ │ ├ ─ ─ persistence │ │ └ ─ ─ the RPC │ ├ ─ ─ registry │ └ ─ ─ usecase ├ ─ ─ CMD │ └ ─ ─ 8 am │ └ ─ ─ main. Go └ ─ ─ vendor ├ ─ ─ vendor packages |...Copy the code

The outermost directory contains three folders:

  • App: root directory of the application package
  • CMD: indicates the main package directory
  • Vendor: Directory of some third-party packages

A clean architecture has some conceptual levels, as shown below:

There are four layers, blue, green, red and yellow from the outside to the inside. I represent the app directory in three colors except blue:

  • Interface: green layer
  • Use case: red layer
  • Domain: yellow layer

The most important thing about a clean architecture is to have interfaces across each layer.

Solid – Yellow layer

In my opinion, the entity layer is like the domain layer in a layered architecture. So to avoid confusion with the entity concepts of domain-driven design, I call this layer the Application/domain layer.

The application/domain consists of three packages:

  • Model: contains aggregate, entity, and value objects
  • Repository: Repository interface that contains aggregate objects
  • Services: Includes application services that depend on the model

I’ll explain the implementation details of each package.

model

The model contains the following user aggregation:

This isn’t really aggregation, but I hope you can add a variety of entities and value objects when you run them locally in the future.

package model

type User struct {
	id    string
	email string
}

func NewUser(id, email string) *User {
	return &User{
		id:    id,
		email: email,
	}
}

func (u *User) GetID() string {
	return u.id
}

func (u *User) GetEmail() string {
	return u.email
}
Copy the code

Aggregation is the boundary of a transaction that is used to ensure consistency of business rules. Therefore, one repository corresponds to one aggregation.

The repository

At this level, the repository should just be an interface, because it should not know the implementation details of persistence. And persistence is a very important essence of this layer.

The implementation of user aggregation storage is as follows:

package repository

import "github.com/hatajoe/8am/app/domain/model"

type UserRepository interface {
	FindAll() ([]*model.User, error)
        FindByEmail(email string) (*model.User, error)
        Save(*model.User) error
}
Copy the code

FindAll retrieves all saved users in the system. Save saves the user to the system. Again, this layer should not know where objects are saved or serialized to.

service

The service layer is a collection of business logic that should not be included in the model layer. For example, the app doesn’t allow any existing email addresses to register. If this validation is done at the model level, we find the following error:

func (u *User) Duplicated(email string) bool {
        // Find user by email from persistence layer...
}
Copy the code

There is no association between the User model and the Duplicated function. To solve this problem, we can add a service layer, as follows:

type UserService struct {
        repo repository.UserRepository
}

func (s *UserService) Duplicated(email string) error {
        user, err := s.repo.FindByEmail(email)
        ifuser ! = nil {return fmt.Errorf("%s already exists", email)
        }
        iferr ! = nil {return err
        }
        return nil
}
Copy the code

Entities include business logic and interfaces through other layers. Business logic should be contained in models and services and should not depend on other layers. If we need to access other layers, we need to go through the repository interface. By reversing dependencies in this way, we can make these packages more isolated and easier to test and maintain.

Use case — red layer

A use case is a unit that applies an operation. In 8AM, listing users and registering users are two use cases. The interfaces of these use cases are represented as follows:

type UserUsecase interface {
    ListUser() ([]*User, error)
    RegisterUser(email string) error
}
Copy the code

Why interfaces? Because these use cases are used at the interface layer — the green layer. We should all define interfaces when we cross layers.

UserUsecase is simply implemented as follows:

type userUsecase struct {
    repo    repository.UserRepository
    service *service.UserService
}

func NewUserUsecase(repo repository.UserRepository, service *service.UserService) *userUsecase {
    return &userUsecase {
        repo:    repo,
        service: service,
    }
}

func (u *userUsecase) ListUser() ([]*User, error) {
    users, err := u.repo.FindAll()
    iferr ! = nil {return nil, err
    }
    return toUser(users), nil
}

func (u *userUsecase) RegisterUser(email string) error {
    uid, err := uuid.NewRandom()
    iferr ! = nil {return err
    }
    iferr := u.service.Duplicated(email); err ! = nil {return err
    }
    user := model.NewUser(uid.String(), email)
    iferr := u.repo.Save(user); err ! = nil {return err
    }
    return nil
}
Copy the code

UserUsercase depends on two packages. The UserRepository interface and the service.UserService structure. These two packages must be injected when the consumer initializes the use case. These dependencies are usually resolved through dependency injection containers, as discussed later.

The ListUser use case retrieves all registered users, and the RegisterUser use case registers new users to the system if the same email address is not already registered.

It is important to note that User, unlike model.user.model.user, may contain a lot of business logic, but it is best for other layers not to know about this specific logic. So I defined a DAO for the use case Users to encapsulate this business logic.

type User struct {
    ID    string
    Email string
}

func toUser(users []*model.User) []*User {
    res := make([]*User, len(users))
    for i, user := range users {
        res[i] = &User{
            ID:    user.GetID(),
            Email: user.GetEmail(),
        }
    }
    return res
}
Copy the code

So why is a service an implementation rather than an interface? Because services don’t depend on other layers. Instead, a repository cuts across other layers, and its implementation depends on device details that other layers should not know, so it is defined as an interface. I think that’s the most important thing in this architecture.

Interface – Green layer

This layer places concrete objects that operate on the boundaries of apis, relational database repositories, or other interfaces. In this case, I’ve added two concrete objects, the memory accessor and the gRPC service.

Memory accessor

I’ve added user-specific repositories as memory accessors.

type userRepository struct {
    mu    *sync.Mutex
    users map[string]*User
}

func NewUserRepository() *userRepository {
    return &userRepository{
        mu:    &sync.Mutex{},
        users: map[string]*User{},
    }
}

func (r *userRepository) FindAll() ([]*model.User, error) {
    r.mu.Lock()
    defer r.mu.Unlock()

    users := make([]*model.User, len(r.users))
    i := 0
    for _, user := range r.users {
        users[i] = model.NewUser(user.ID, user.Email)
        i++
    }
    return users, nil
}

func (r *userRepository) FindByEmail(email string) (*model.User, error) {
    r.mu.Lock()
    defer r.mu.Unlock()

    for _, user := range r.users {
        if user.Email == email {
            return model.NewUser(user.ID, user.Email), nil
        }
    }
    return nil, nil
}

func (r *userRepository) Save(user *model.User) error {
    r.mu.Lock()
    defer r.mu.Unlock()

    r.users[user.GetID()] = &User{
        ID:    user.GetID(),
        Email: user.GetEmail(),
    }
    return nil
}
Copy the code

This is a concrete implementation of the repository. If we want to save users to a database or elsewhere, we need to implement a new repository. However, we do not need to modify the model layer. It’s amazing.

User is defined only in this package. This is also to solve the problem of unsealing business logic between different layers.

type User struct {
    ID    string
    Email string
}
Copy the code

GRPC service

I think gRPC services should also be at the interface layer. In the directory app/interface/ RPC:

├── ├─ all exercises, all exercises, all exercises, all exercises, all exercises, all exercises v1.goCopy the code

The Protocol folder contains the protocol cache DSL file (user_service.proto) and the generated RPC service code (user_service.pb.go).

User_service. go is a wrapper around the endpoint handler for gRPC:

type userService struct {
    userUsecase usecase.UserUsecase
}

func NewUserService(userUsecase usecase.UserUsecase) *userService {
    return &userService{
        userUsecase: userUsecase,
    }
}

func (s *userService) ListUser(ctx context.Context, in *protocol.ListUserRequestType) (*protocol.ListUserResponseType, error) {
    users, err := s.userUsecase.ListUser()
    iferr ! = nil {return nil, err
    }

    res := &protocol.ListUserResponseType{
        Users: toUser(users),
    }
    return res, nil
}

func (s *userService) RegisterUser(ctx context.Context, in *protocol.RegisterUserRequestType) (*protocol.RegisterUserResponseType, error) {
    iferr := s.userUsecase.RegisterUser(in.GetEmail()); err ! = nil {return &protocol.RegisterUserResponseType{}, err
    }
    return &protocol.RegisterUserResponseType{}, nil
}

func toUser(users []*usecase.User) []*protocol.User {
 res := make([]*protocol.User, len(users))
    for i, user := range users {
        res[i] = &protocol.User{
            Id:    user.ID,
            Email: user.Email,
        }
    }
    return res
}
Copy the code

UserService relies only on the use-case interface. If you want to use use cases for other layers (such as GUI), you can implement this interface your way.

V1. go is an object dependency resolver that uses dependency injection containers:

func Apply(server *grpc.Server, ctn *registry.Container) {
    protocol.RegisterUserServiceServer(server, NewUserService(ctn.Resolve("user-usecase").(usecase.UserUsecase)))
}
Copy the code

V1. go applies the package retrieved from Registry.Container to the gRPC service.

Finally, let’s look at the implementation of the dependency injection container.

registered

Registries are dependency injection containers that resolve object dependencies. The dependency injection container I use is github.com/sarulabs/di…

Sarulabs/DI: Go (Golang) dependency injection container. Please register a GitHub account to contribute to sarulabs/ DI development

github.com/surulabs/di can be used simply as follows:

type Container struct {
    ctn di.Container
}

func NewContainer() (*Container, error) {
    builder, err := di.NewBuilder()
    iferr ! = nil {return nil, err
    }

    if err := builder.Add([]di.Def{
        {
            Name:  "user-usecase", Build: buildUserUsecase, }, }...) ; err ! = nil {return nil, err
    }

    return &Container{
        ctn: builder.Build(),
    }, nil
}

func (c *Container) Resolve(name string) interface{} {
    return c.ctn.Get(name)
}

func (c *Container) Clean() error {
    return c.ctn.Clean()
}

func buildUserUsecase(ctn di.Container) (interface{}, error) {
    repo := memory.NewUserRepository()
    service := service.NewUserService(repo)
    return usecase.NewUserUsecase(repo, service), nil
}
Copy the code

In the example above, I use the buildUserUsecase function to associate the string user-usecase with a specific use-case implementation. This way we can replace the concrete implementation of any use case simply by registering in one place.


Thank you for reading this primer. Your valuable suggestions are welcome. If you have any ideas or suggestions for improvement, please feel free to comment!

If you find any mistakes in your translation or other areas that need to be improved, you are welcome to the Nuggets Translation Program to revise and PR your translation, and you can also get the corresponding reward points. The permanent link to this article at the beginning of this article is the MarkDown link to this article on GitHub.


The Nuggets Translation Project is a community that translates quality Internet technical articles from English sharing articles on nuggets. The content covers Android, iOS, front-end, back-end, blockchain, products, design, artificial intelligence and other fields. If you want to see more high-quality translation, please continue to pay attention to the Translation plan of Digging Gold, the official Weibo, Zhihu column.