Nel retrobottega

How I structure big Go web projects

· Marco Piovanello

It’s the 1 million dollars question: “what’s the best way to structure your Go web project?”.
There’s none.
But you can adapt one to your needs. One way I found working for me it’s the following: a domain-ish driven architecture with a modular approach.

The main star here is fx by Uber.

What’s fx?

Fx is A dependency injection based application framework for Go. according to fx’s GitHub page.
It can helps you divide your project in modules and dependecies in providers and manage theirs entire life cylcles.

What’s really nice about fx is that there’s no build time and //go:generate / //go:build / code genaration shenanigans unlike wire and most importantly no reflection.

Defining a module structure

A module in follows this structure:

📦internal
 ┣ 📂user           ->  module package
 ┃ ┣ 📂db           ->  contains db models and queries
 ┃ ┃ ┣ 📜db.go
 ┃ ┃ ┣ 📜models.go
 ┃ ┃ ┗ 📜query.sql.go
 ┃ ┣ 📂repository   ->  repository handles db accesing logic
 ┃ ┃ ┗ 📜user.go
 ┃ ┣ 📂rest
 ┃ ┃ ┗ 📜handler.go ->  rest handler functions
 ┃ ┣ 📂service      ->  service handles domain level logic
 ┃ ┃ ┗ 📜user.go
 ┃ ┣ 📜module.go    ->  defines and exports a module
 ┃ ┣ 📜query.sql    ->  sqlc queries
 ┃ ┣ 📜schema.sql   ->  sqlc db models
 ┃ ┗ 📜sqlc.yaml    ->  generates db package
 ┣ 📜user.go        ->  domain level structs and validate func
 ┗ 📜validation.go  ->  module agnostic validation utils (regexes, ...)

It ensures isolated and self-containing modules for each domain.

Let’s look at module.go file and we’ll top-down from there.

package user

import (
  "github.com/marcopeocchi/fx-sample-app/internal/user/repository"
  "github.com/marcopeocchi/fx-sample-app/internal/user/rest"
  "github.com/marcopeocchi/fx-sample-app/internal/user/service"

  "github.com/gin-gonic/gin"
  "go.uber.org/fx"
)

var Module = fx.Module(
  "user",
  fx.Provide(repository.NewRepository),
  fx.Provide(service.NewService),
  fx.Provide(rest.NewHandler),
  fx.Invoke(func(h *rest.Handler, g *gin.RouterGroup) {
    h.RegisterRoutes(g)
  }),
)

user.go

This file contains all domain level data and validation functions.

package internal

import (
  "time"

  validation "github.com/go-ozzo/ozzo-validation/v4"
  "github.com/go-ozzo/ozzo-validation/v4/is"
)

type User struct {
  ID        int64     `json:"id"`
  Admin     bool      `json:"admin"`
  Email     string    `json:"email"`
  Username  string    `json:"username"`
  Password  string    `json:"password"`
  CreatedAt time.Time `json:"createdAt"`
}

func (u User) Validate() error {
  return validation.ValidateStruct(&u,
    validation.Field(&u.Email, validation.Required, is.Email),
    validation.Field(&u.Username, validation.Length(4, 32), validation.Required),
    validation.Field(&u.Password, validation.Length(8, 0), validation.Required),
  )
}

repository/user.go

A repository handles all db/data access layer functionalites.
Theoretically if you need a caching layer it should be implemented here.

type Repository struct {
  conn *pgxpool.Pool
  q    *db.Queries
}

func NewRepository(conn *pgxpool.Pool) *Repository {
  return &Repository{
    conn: conn
    q:    db.New(conn)
  }
}

func (r *Repository) GetUserById(ctx context.Context, id int64) (*db.User, error) {
  model, err := r.q.GetUserById(id)
  if err != nil {
    return nil, err
  }

  return &model, err
}

service/user.go

A service needs to convert objects to the domain layer and call repository or other repositories in order to access data.

type Service struct {
  repo *repository.Repository
}

func NewService(r *repository.Repository) *Service {
  return &Service{
    repo: r
  }
}

func (s *Service) GetUserById(ctx context.Context, id int64) (*internal.User, error) {
  model, err := s.repo.GetUserById(id)
  if err != nil {
    return nil, err
  }
  // convert it to a domain level user struct
  return &internal.User{
    Id:        model.Id
    Admin:     model.Admin
    Username:  model.Username,
    Password:  model.Password,
    CreatedAt: model.CreatedAt.Time
    Email:     model.Email,
  }, nil
}

rest/handler.go

A REST handler it quite self-explanatory provides a REST interface to access data. It is framework agnostic, in this example I’ll use Gin, but you can use straight net/http which is what I will also normally do.

type Handler struct {
  service *service.Service
}

func NewHandler(s *service.Service) *Handler {
  return &Handler{
    service: s
  }
}

func (s *Service) getUserById(router *gin.RouterGroup) {
  router.GET("/user/:id", func(ctx *gin.Context) {
    id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
    if err != nil {
      ctx.AbortWithStatusJSON(http.StatusBadRequest, err.Error())
      return
    }

    user, err := h.service.GetUserById(ctx, id)
    if err != nil {
      ctx.AbortWithStatusJSON(http.StatusInternalServerError, err.Error())
      return
    }

    ctx.JSON(http.StatusOK, users)
  })
}

func (h *Handler) RegisterRoutes(router *gin.RouterGroup) {
  h.getUserById(router)
}

Let’s define an app and it’s dependencies to provide/inject and wrap it up.

main.go

func main() {
  fx.New(
    fx.Provide(newLogger),
    fx.Provide(
      newGin,
      newApiV1Group,
      newPostgresPool,
    ),
    user.Module,
    fx.Invoke(run),
  ).Run()
}

func newPostgresPool(lc fx.Lifecycle, logger *zap.Logger) *pgxpool.Pool {
  dsn := os.Getenv("POSTGRES_DSN")

  ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
  defer cancel()

  pool, err := pgxpool.New(ctx, dsn)

  if err != nil {
    logger.Sugar().Fatalln(err)
  }

  lc.Append(fx.Hook{
    OnStop: func(ctx context.Context) error {
      cancel()
      pool.Close()
      return nil
    },
  })
  return pool
}

func newGin() *gin.Engine {
  return gin.Default()
}

func run(r *gin.Engine) {
  r.Run()
}

func newApiV1Group(r *gin.Engine) *gin.RouterGroup {
  r.Use(gzip.Gzip(gzip.DefaultCompression))
  r.Use(cors.New(cors.Config{
    AllowOrigins:     []string{"*"},
    AllowMethods:     []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
    AllowHeaders:     []string{"Origin", "Content-Type"},
    ExposeHeaders:    []string{"Content-Length", "Content-Type"},
    AllowCredentials: true,
  }))
  return r.Group("/api/v1")
}

func newLogger(lc fx.Lifecycle) *zap.Logger {
  logger, err := zap.NewProduction()
  if err != nil {
    os.Exit(1)
  }
  lc.Append(fx.Hook{
    OnStop: func(ctx context.Context) error {
      return logger.Sync()
    },
  })
  return logger
}

Conclusions

This structure really shines with many modules 4+ I’ll say.
Adding or removing dependencies is quite easy and straightforward, let’s say i wanted to add a redis client: what I need is creating a new constructor function and pass it to the fx.Provide as an additional argument, then the repository constructor will also be modified to accept a the redis client and voilà.