2022.07.31
Jalex Chang
Introduction
Architecture overview
Architectural designs
Component designs
Discussions
Summary
[1] Crescendo's go-clean-arch, https://github.com/chatbotgang/go-clean-arch
[2] Go clean architecture project - wild-workouts, https://github.com/ThreeDotsLabs/wild-workouts-go-ddd-example
[3] Uncle Bob's clean architecture, https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html
In this tech talk, we want to introduce a simple but maintainable Go clean architecture, commonly used in Crescendo Lab.
Topics will be covered in the talk:
Engineering challenges in Crescendo Lab
Design goals of a new architecture
Designs and decisions we have made
Pros and cons of the proposed architecture
The proposed architecture and its designs are open to the public.
Crescendo Lab is a regional Martech start-up:
Challenges in eng teams:
Domain-Driven Design
(Ubiquitous language)
Clean Architecture
Go Practices
Try and Error Loop......
4 layers, making things simple but organized:
internal
│
├── adapter
│ ├── eventbroker
│ ├── repository
│ │ └── postgres
│ │ ├── good_repository.go
│ │ ├── good_repository_test.go
│ │ ├── postgres_repository.go
│ │ └── postgres_repository_test.go
│ └── server
│
├── app
│ └── service
│ ├── auth
│ └── barter
│ ├── exchange_service.go
│ ├── exchange_service_test.go
│ ├── good_service.go
│ ├── good_service_test.go
│ ├── interface.go
│ ├── service.go
│ └── service_test.go
internal
│
├── domain
│ ├── barter
│ │ ├── exchange.go
│ │ ├── exchange_test.go
│ │ ├── good.go
│ │ ├── good_test.go
│ │ ├── trader.go
│ │ └── trader_test.go
│ └── common
│ ├── error.go
│ └── error_test.go
│
└── router
├── handler.go
├── handler_auth_trader.go
├── handler_barter_exchange.go
├── handler_barter_good.go
├── middleware.go
├── middleware_auth.go
├── middleware_logger.go
└── response.go
Design principles
There is nothing wrong with only having data structures in domain.
We aggregate business logic through refactorization.
Design principles
Design principles
Design principles
Form DB integration tests as unit tests
API model ≠ Domain model ≠ DB model
type CustomerTag struct {
ID int
CustomerID int
Type TagType
Name string
CreatedAt time.Time
}
type repoCsutomerTag struct {
ID int `db:"id"`
CustomerID int `db:"customer_id"`
TagID int `db:"tag_id"`
CreatedAt time.Time `db:"created_at"`
}
type repoChannelTag struct {
ID int `db:"id"`
ChannelID int `db:"channel_id"`
Name string `db:"name"`
Type string `db:"type"`
}
func ListCustomerTags(app *app.Application) gin.HandlerFunc {
type Tag struct {
ID int `json:"id"`
Name string `json:"name"`
CreatedAt int `json:"created_at"`
...
}
type Response struct {
CustomerID int `json:"customer_id"`
MarketingTags []Tag `json:"marketing_tags"`
ServiceTags []Tag `json:"service_tags"`
SalseTags []Tag `json:"salse_tags"`
}
}
Adapter
Domain
Router
Good designs are invisible.
// internal/app/application.go
func NewApplication(ctx context.Context, wg *sync.WaitGroup, params ApplicationParams) (*Application, error) {
// 1. Init adapters
pgRepo := postgres.NewPostgresRepository(ctx, db)
authServer := server.NewAuthServer(ctx, server.AuthServerParam{})
// 2. Create the applicaiton and init services
app := &Application{
AuthService: auth.NewAuthService(ctx, auth.AuthServiceParam{
AuthServer: authServer,
TraderRepo: pgRepo,
}),
BarterService: barter.NewBarterService(ctx, barter.BarterServiceParam{
GoodRepo: pgRepo,
}),
}
return app, nil
}
func registerAPIHandlers(router *gin.Engine, app *app.Application) {
barterGroup := v1.Group("/barter", BearerToken.Required())
{
barterGroup.POST("/goods", PostGood(app))
barterGroup.GET("/goods", ListMyGoods(app))
barterGroup.DELETE("/goods/:good_id", RemoveMyGood(app))
}
}
func PostGood(app *app.Application) gin.HandlerFunc {
return func(c *gin.Context) {
good, err := app.BarterService.PostGood(ctx, barter.PostGoodParam{...})
if err != nil {...}
}
}
Inject application to handlers:
Handlers call services methods through the application:
func (s *BarterService) PostGood(ctx context.Context, param PostGoodParam) (*barter.Good, common.Error) {
good, err := s.goodRepo.CreateGood(ctx, barter.NewGood(param.Trader, param.GoodName))
if err != nil {...}
}
Services call adapter methods through interface:
// internal/domain/common/error.go
type Error interface {
Error() string
ClientMsg() string
}
func NewError(code ErrorCode, err error, opts ...ErrorOption) Error {
e := DomainError{code: code, err: err}
// Handle error options
for _, o := range opts {
o(&e)
}
return e
}
type DomainError struct {
code ErrorCode
err error
clientMsg string
remoteStatus int
detail map[string]interface{}
}
type ErrorCode struct {
Name string
StatusCode int
}
// Authentication and Authorization error codes
var ErrorCodeAuthPermissionDenied = ErrorCode{
Name: "AUTH_PERMISSION_DENIED",
StatusCode: http.StatusForbidden,
}
// Business-related errors
var ErrorCodeParameterEmailNotExist = ErrorCode{
Name: "PARAMETER_EMAIL_NOT_EXIST",
StatusCode: http.StatusBadRequest,
}
var ErrorCodeParameterInvalidPassword = ErrorCode{
Name: "PARAMETER_INVALID_PASSWORD",
StatusCode: http.StatusBadRequest,
}
var ErrorCodePaymentExpiredPlan = ErrorCode{
Name: "PAYMENT_EXPIRED_PLAN",
StatusCode: http.StatusPaymentRequired,
}
Custom Error Domain
Pre-defined ErrorCode:
func ParseTraderFromToken(signedToken string, signingKey []byte) (*Trader, common.Error) {
if !token.Valid {
msg := "invalid token"
return nil, common.NewError(common.ErrorCodeParameterInvalid, errors.New(msg), common.WithMsg(msg))
}
}
func (s *BarterService) ExchangeGoods(ctx context.Context, param ExchangeGoodsParam) common.Error {
if !requestGood.IsMyGood(param.Trader){
return common.NewError(common.ErrorCodeAuthPermissionDenied, nil)
}
}
func (r *PostgresRepository) GetTraderByEmail(ctx context.Context, email string) (*barter.Trader, common.Error) {
if err = r.db.GetContext(ctx, &row, query, args...); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, common.NewError(common.ErrorCodeResourceNotFound, err)
}
return nil, common.NewError(common.ErrorCodeRemoteProcess, err)
}
}
Domain
Application
Adapter
// internal/router/response.go
func respondWithError(c *gin.Context, err error) {
// 1. parse error mesages
errMessage := parseError(err)
// 2. log out error messages
ctx := c.Request.Context()
zerolog.Ctx(ctx).Error().Err(err).Str("component", "handler").Msg(errMessage.Message)
// 3. respond error messages
c.AbortWithStatusJSON(errMessage.Code, errMessage)
}
func parseError(err error) ErrorMessage {
var domainError common.DomainError
_ = errors.As(err, &domainError)
return ErrorMessage{
Name: domainError.Name(),
Code: domainError.HTTPStatus(),
Message: domainError.ClientMsg(),
RemoteCode: domainError.RemoteHTTPStatus(),
Detail: domainError.Detail(),
}
}
// internal/router/middleware_logger.go
func SetGeneralMiddlewares(ctx context.Context, ginRouter *gin.Engine) {
ginRouter.Use(requestid.New())
ginRouter.Use(LoggerMiddleware(ctx))
}
func LoggerMiddleware(rootCtx context.Context) gin.HandlerFunc {
return func(c *gin.Context) {
// 1. add RequestID into the logger of the request context
requestID := requestid.Get(c)
zlog := zerolog.Ctx(rootCtx).With().
Str("requestID", requestID).
Logger()
c.Request = c.Request.WithContext(zlog.WithContext(rootCtx))
// 2. call next handlers
c.Next()
}
}
// logger wraps the execution context with component info
func (s *ChatService) logger(ctx context.Context) *zerolog.Logger {
l := zerolog.Ctx(ctx).With().Str("component", "chat-service").Logger()
return &l
}
func (s *ChatService) SendMessageToCutomer(ctx context.Context, param Param) common.Error {
if err := s.lineServer.SendMessages(ctx, messages); err != nil {
s.logger(ctx).Error().Err(err).Msg("failed to send messages to Line")
return err
}
}
Q1: What are the pros and cons of the proposed architecture?
Pros
Cons
Q2: What use cases are the proposed architecture?
Q3: How to introduce new tech/architecture to teams?
Architecture-wise
Community-wise
In this sharing, we have introduced a Go Clean Architecture that is internally used in Crescendo Lab.
To make the codebase simple and straight, the architecture has only four layers: domain, application, router, and adapter layers.
To make developers happy and easily handle their jobs, the details matter.
E.g. error domain and traceable logging.
The proposed architecture and its designs are open to the public (chatbotgang/go-clean-arch).
Good architecture comes from observations and comminucations.
We are hiring now! Feel free to chat with us in Room TR 312 (研揚大樓 312室).