Opinionated guide to structuring Golang apps

Jan 30, 2021

|

MBV

2022 update. At the time of writing this update, it's been over a year since I published this article. At the end of this article I promise a part two, which I must admit, will probably never come for a couple of reasons. I no longer use this structure (I still think this article gives people some help to get going, but would like to update it at one point. I suggest you checkout this repo. Secondly, in regards to integration testing, I've just relased a full-length article about it which you can find here.

Go is an amazing language. It’s simple, easy to reason about and gives you much tooling right out of the box. However, when I started with Go I struggled to figure out how to structure my applications in a way that didn’t use “enterprisey” approaches. This is my take on structuring Golang applications that start out simple, but with the flexibility to grow, and what I would have wanted when I started with Go.

Disclaimer

This article is based on the following people (link to their Twitter):

All of the above-mentioned people have articles and tutorials that are amazing on their own, which you should definitely checkout.

If you just want to see the code for this article’s project, check out the Github repository.

Project & tools

It is always frustrating to learn a new language and having to create the same to-do app over and over again. So we are going to make an application that allows people to create an account, where they can track their weight and calculate their nutritional requirements (inspired by weight increase during lockdown). Feel free to add to this application with features you see fit.

Now, I’m assuming some familiarity with Golang and programming in general, as this is a practical example of how to structure a Go application. We will be using the following:

Elements of a robust and scalable structure

Spend some time working in software development and you quickly learn how programs sometimes can be brittle and other times rigid. Therefore, I look for these three things in my application structure:

Writing programs in a way that also makes it easy to write tests dramatically affect developer productivity. Along with readability, it not only makes it easier for you to maintain and improve old code. You also make it easier for new people to add to the project as they can feel somewhat safe when changing or adding code as you already have test in place. Lastly, having adaptability means your project can adapt to changing needs and complexity.

Starting out as simple as possible have the advantage of you being able to quickly iterate on new ideas getting to the actual problem solving right from the get-go. Most developers don’t need to think about domain-driven development when starting new projects, and it would probably be a waste of time in most cases (unless, of course, you are working in Big Co. but then why would you be reading an article for beginner Go developers).

The tutorial is split into three parts:

This should hopefully give you an overview of the overall structure, a step-by-step guide on how to add a service and finally how easy it is to write test and develop with TDD.

The application structure

Alright, enough talk, let’s go going. Create a new folder wherever you like to store your projects called weight-tracker and run the following command inside the folder:

1go mod init weight-tracker

The application is going to follow a structure that looks something like this:

 1weight-tracker
 2- cmd
 3  - server
 4    main.go
 5- pkg
 6  - api
 7    user.go
 8    weight.go
 9  - app
10    server.go
11    handlers.go
12    routes.go
13  - repository
14    storage.go

Go ahead and create the above structure in your designated folder.

Most go projects seem to be following a convention of having a cmd and a pkg directory. The cmd will be the entry point into the program and gives you the flexibility to interact with the program in several ways. he pkg directory will contain everything else:

We will be working with four packages:

The main package should be self-explanatory if you’ve ever done any golang programming before. All of our services, i.e. user and weight service, are going into the api package along with a definitions file that will contain all of our structs (we will create this later). In the app package, we will have our server, handlers and routes. Lastly, we have repository that will contain all of our code related to database operations.

Open up main.go and add the following:

 1package main
 2
 3import (
 4	"database/sql"
 5	"fmt"
 6	"github.com/gin-contrib/cors"
 7	"github.com/gin-gonic/gin"
 8	"os"
 9	"weight-tracker/pkg/api"
10	"weight-tracker/pkg/app"
11	"weight-tracker/pkg/repository"
12)
13
14func main() {
15	if err := run(); err != nil {
16		fmt.Fprintf(os.Stderr, "this is the startup error: %s\\n", err)
17		os.Exit(1)
18	}
19}
20
21// func run will be responsible for setting up db connections, routers etc
22func run() error {
23	// I'm used to working with postgres, but feel free to use any db you like. You just have to change the driver
24	// I'm not going to cover how to create a database here but create a database
25	// and call it something along the lines of "weight tracker"
26	connectionString := "postgres://postgres:postgres@localhost/**NAME-OF-YOUR-DATABASE-HERE**?sslmode=disable"
27
28	// setup database connection
29	db, err := setupDatabase(connectionString)
30
31	if err != nil {
32		return err
33	}
34
35	// create storage dependency
36	storage := repository.NewStorage(db)
37
38	// create router dependecy
39	router := gin.Default()
40	router.Use(cors.Default())
41
42	// create user service
43	userService := api.NewUserService(storage)
44
45	// create weight service
46	weightService := api.NewWeightService(storage)
47
48	server := app.NewServer(router, userService, weightService)
49
50	// start the server
51	err = server.Run()
52
53	if err != nil {
54		return err
55	}
56
57	return nil
58}
59
60func setupDatabase(connString string) (*sql.DB, error) {
61	// change "postgres" for whatever supported database you want to use
62	db, err := sql.Open("postgres", connString)
63
64	if err != nil {
65		return nil, err
66	}
67
68	// ping the DB to ensure that it is connected
69	err = db.Ping()
70
71	if err != nil {
72		return nil, err
73	}
74
75	return db, nil
76}

You should have quite a few errors showing in your IDE now, we are fixing that in a moment. But first off, let me explain what is going on here. In our func main() we call a method named run, that returns an error (or nil, if there is no error). If run should return an error our programs exits and gives us an error message. Setting up our main function in this way allows us to test it and thereby following the element of a robust service structure, testability. This way of setting up the main function comes from Mat Ryer, who talks more about it in his blog post.

The next two topics we need to discuss are Clean Architecture and Dependency Injection. You might have heard about Clean Architecture under a different name, as a few people wrote about it around the same time. The majority of my inspiration comes from Robert C. Martin so this is who I will be referencing.

We are not going to be religiously following Clean Architecture but mainly adopt the idea of The Dependency Rule. Take a look at the below picture:

https://cdn-images-1.medium.com/max/1600/1*_5WeMzRt5aCVxXNWLlxAJw.png

Source: Robert C. Martin’s blog — Clean Coder Blog

A quote from Robert Martin’s article helps define this rule:

This rule says that source code dependencies can only point inwards. Nothing in an inner circle can know anything at all about something in an outer circle.

In rough terms, the inner layers should not know about the outer layers. This allows us to practically change the database we use. Say we are changing from PostgreSQL to MySQL or exposing our API through gRPC instead of HTTP. You would most likely never do this but it does speak for the adaptability of this concept.

To achieve this, we are going to use dependency injection (DI for short). Basically, DI is the idea that services should receive their dependencies when they are created. It allows us to decouple the creation of a service’s dependencies from the creation of the service itself. This will be helpful when we get to testing our code. If you want to read more about DI, I suggest this article.

I learn best by looking at actual code, so let’s start by adding some of the missing code that will make the code in main.go make more sense. Open up storage.go and add the following:

 1package repository
 2
 3import (
 4	"database/sql"
 5	_ "github.com/lib/pq"
 6)
 7
 8type Storage interface{}
 9
10type storage struct {
11	db *sql.DB
12}
13
14func NewStorage(db *sql.DB) Storage {
15	return &storage{
16		db: db,
17	}
18}

This allows us to create a new storage component where and whenever we want, as long as it receives a valid db argument of type *sql.DB.

As you may have noticed, we have a lowercase and an uppercase version of storage, one is a struct and one is an interface. We will define any methods (like CRUD operations) on the lowercase version of storage and define the methods in the uppercase version. By doing so, we can now easily mock out storage for unit testing. Also, we now get some nice suggestions from our IDE when we add methods to the storage interface when we haven’t implemented the methods.

Now, lets set up the base structure of one of our services, user.go. This should give you a feel for how the API package is going to be structuring services. You will have to repeat this for the weight.go service as well. Just copy-paste the content of user.goand change the naming. Open up user.go and add the following:

 1package api
 2
 3// UserService contains the methods of the user service
 4type UserService interface{}
 5
 6// UserRepository is what lets our service do db operations without knowing anything about the implementation
 7type UserRepository interface{}
 8
 9type userService struct {
10	storage UserRepository
11}
12
13func NewUserService(userRepo UserRepository) UserService {
14	return &userService{
15		storage: userRepo,
16	}
17}

Notice that our user service have a repository dependency. We will define repository methods (i.e. database operations) here later on, but the important part is that we only define the methods we need. Since we are not interested in the implementation of these methods, only the behaviour, we can write mock functions that suit a given test case.

This might seem a bit fluffy right now but bear with me for now, it will make sense later on. Let’s get a running application that we can add actual logic to the services.

Open up server.go and add the following:

 1package app
 2
 3import (
 4	"github.com/gin-gonic/gin"
 5	"log"
 6	"weight-tracker/pkg/api"
 7)
 8
 9type Server struct {
10	router        *gin.Engine
11	userService   api.UserService
12	weightService api.WeightService
13}
14
15func NewServer(router *gin.Engine, userService api.UserService, weightService api.WeightService) *Server {
16	return &Server{
17		router:        router,
18		userService:   userService,
19		weightService: weightService,
20	}
21}
22
23func (s *Server) Run() error {
24	// run function that initializes the routes
25	r := s.Routes()
26
27	// run the server through the router
28	err := r.Run()
29
30	if err != nil {
31		log.Printf("Server - there was an error calling Run on router: %v", err)
32		return err
33	}
34
35	return nil
36}

Next, open routes.go and add the following:

 1package app
 2
 3import "github.com/gin-gonic/gin"
 4
 5func (s *Server) Routes() *gin.Engine {
 6	router := s.router
 7
 8	// group all routes under /v1/api
 9	v1 := router.Group("/v1/api")
10	{
11		v1.GET("/status", s.ApiStatus())
12	}
13
14	return router
15}

We are going to take advantage of gin’s group functionality so we can easily group our endpoints after the resources they intend to serve. Next up, let’s add a handler so we can actually make calls to the status endpoint and verify that our application is running. Open up handlers.go:

 1package app
 2
 3import (
 4	"github.com/gin-gonic/gin"
 5	"net/http"
 6)
 7
 8func (s *Server) ApiStatus() gin.HandlerFunc {
 9	return func(c *gin.Context) {
10		c.Header("Content-Type", "application/json")
11
12		response := map[string]string{
13			"status": "success",
14			"data":   "weight tracker API running smoothly",
15		}
16
17		c.JSON(http.StatusOK, response)
18	}
19}

At this point, we only need to sync a few dependencies: Gin Web Framework and a driver for PostgreSQL. Go ahead and type the following into your terminal:

1  go get github.com/gin-contrib/cors
2  go get github.com/gin-gonic/gin
3  go get github.com/lib/pg

Everything should be ready now, so go into your terminal and write: go go run cmd/server/main.go, visit sh http://localhost:8080/v1/api/status, and you should receive a message along the lines of:

1{
2	"data": "weight tracker API running smoothly",
3	"status": "success"
4}

If you don’t get the above message, please go back to the previous steps and see if you missed something or checkout the accompanying GitHub repository.

User Service implementation

At this point, we are ready to start building the meat of the application. To do so, we probably need to set up a database with tables and relations. To do this, we are going to use the library [golang-migrate](https://github.com/golang-migrate/migrate/). I personally like this library as it has a CLI tool to add migrations which enables me to do things like adding Makefile commands to create migrations. I encourage you to give the library’s documentation a look, it is an amazing project.

I won’t cover how to set this up as the article would be too long. For now, go into your terminal, make sure you are in the repository folder and run:

1git clone https://gist.github.com/ed090d782dc6ebb35e344ff82aafdddf.git

This clones the migrations needed for the project. You should also now have a folder named ed090d782dc6ebb35e344ff82aafdddf, ****lets change that to migrations by running:

1mv ed090d782dc6ebb35e344ff82aafdddf migrations

The last thing we need now is to add the RunMigrations method to storage.go:

 1// update your imports to look like this:
 2import (
 3	"database/sql"
 4	"errors"
 5	"github.com/golang-migrate/migrate/v4"
 6	_ "github.com/golang-migrate/migrate/v4/database/postgres"
 7	_ "github.com/golang-migrate/migrate/v4/source/file"
 8	_ "github.com/lib/pq"
 9	"path/filepath"
10	"runtime"
11)
12
13// add the RunMigrations method to our interface
14type Storage interface {
15	RunMigrations(connectionString string) error
16}
17
18// add this below NewStorage
19func (s *storage) RunMigrations(connectionString string) error {
20	if connectionString == "" {
21		return errors.New("repository: the connString was empty")
22	}
23	// get base path
24	_, b, _, _ := runtime.Caller(0)
25	basePath := filepath.Join(filepath.Dir(b), "../..")
26
27	migrationsPath := filepath.Join("file://", basePath, "/pkg/repository/migrations/")
28
29	m, err := migrate.New(migrationsPath, connectionString)
30
31	if err != nil {
32		return err
33	}
34
35	err = m.Up()
36
37	switch err {
38	case errors.New("no change"):
39		return nil
40	}
41
42	return nil
43}

To run the migrations, open up main.go and add the following:

 1// everything stays the same, so add this below
 2// storage := repository.NewStorage(db)
 3// run migrations
 4
 5// note that we are passing the connectionString again here. This is so
 6// we can easily run migrations against another database, say a test version,
 7// for our integration- and end-to-end tests
 8err = storage.RunMigrations(connectionString)
 9
10if err != nil {
11    return err
12}

You should now have two tables in your database: user and weight. Let’s get started on writing some actual business logic.

We want to let people create an account through our API. So let’s started by defining what a user request is, create a file called under the API folder definitions.go and add the following:

 1package api
 2
 3type NewUserRequest struct {
 4	Name          string `json:"name"`
 5	Age           int    `json:"age"`
 6	Height        int    `json:"height"`
 7	Sex           string `json:"sex"`
 8	ActivityLevel int    `json:"activity_level"`
 9	WeightGoal    string `json:"weight_goal"`
10	Email         string `json:"email"`
11}

We are defining what a new user request should look like. Note that this might be different from what a user struct would look like, we define our struct to only include the data we need. Next up, open user.go and the following to UserService and UserRepository:

1// user.go
2type UserService interface {
3    New(user NewUserRequest) error
4}
5
6// User repository is what lets our service do db operations without knowing anything about the implementation
7type UserRepository interface {
8    CreateUser(NewUserRequest) error
9}

Here we define a method on our UserService, called New and a method on the UserRepository called CreateUser. Remember we talked about The Dependency Rule earlier? This is what’s going on with the CreateUser method on UserRepository, our service does not know about the actual implementation of the method, what type of database it is etc. Just that there is a method called CreateUser and takes in a NewUserRequest and returns an error. The benefit of this is twofold: we get some indication from our IDE that a method is missing (open up main.go and check api.NewUserService) and what it needs, and it allows us to easily write unit tests. You should also see an error from NewUserServicein user.go, telling us that we are missing a method. Let’s fixed that, add the following:

 1// add these imports after the package declaration
 2import (
 3	"errors"
 4	"strings"
 5)
 6
 7// add this after NewUserService
 8func(u * userService) New(user NewUserRequest) error {
 9    // do some basic validations
10    if user.Email == "" {
11        return errors.New("user service - email required")
12    }
13
14    if user.Name == "" {
15        return errors.New("user service - name required")
16    }
17
18    if user.WeightGoal == "" {
19        return errors.New("user service - weight goal required")
20    }
21
22    // do some basic normalisation
23    user.Name = strings.ToLower(user.Name)
24    user.Email = strings.TrimSpace(user.Email)
25
26    err := u.storage.CreateUser(user)
27
28    if err != nil {
29        return err
30    }
31
32    return nil
33}

We do some basic validations and normalisation, but this method could definitely be improved upon. We still need to add the CreateUser method, so open up storage.go and add the following to the CreateUser method to the Storage interface:

1// storage.go
2type Storage interface {
3    RunMigrations(connectionString string) error
4    CreateUser(request api.NewUserRequest) error
5}

Notice that this makes the error in main.go go away, but results in a new error on the NewStorage function. We need to implement the method, just like we did with the UserService. Add this below RunMigrations:

 1// add "log" to imports to your imports look like this:
 2import (
 3	"database/sql"
 4	"errors"
 5	"github.com/golang-migrate/migrate/v4"
 6	_ "github.com/golang-migrate/migrate/v4/database/postgres"
 7	_ "github.com/golang-migrate/migrate/v4/source/file"
 8	_ "github.com/lib/pq"
 9	"log"
10	"path/filepath"
11	"runtime"
12	"weight-tracker/pkg/api"
13)
14
15// add this below RunMigrations
16func (s *storage) CreateUser(request api.NewUserRequest) error {
17	newUserStatement := `
18		INSERT INTO "user" (name, age, height, sex, activity_level, email, weight_goal)
19		VALUES ($1, $2, $3, $4, $5, $6, $7);
20		`
21
22	err := s.db.QueryRow(newUserStatement, request.Name, request.Age, request.Height, request.Sex, request.ActivityLevel, request.Email, request.WeightGoal).Err()
23
24	if err != nil {
25		log.Printf("this was the error: %v", err.Error())
26		return err
27	}
28
29	return nil
30}

Again, this code could probably do with some improvements, but I’m going to keep it short.

Now, all that is left to do is to expose this through HTTP so that people can actually start using our API and create their accounts. Open handlers.go and add the following:

 1// update your imports to look like this
 2import (
 3	"github.com/gin-gonic/gin"
 4	"log"
 5	"net/http"
 6	"weight-tracker/pkg/api"
 7)
 8
 9// add this below APIStatus method
10func (s *Server) CreateUser() gin.HandlerFunc {
11	return func(c *gin.Context) {
12		c.Header("Content-Type", "application/json")
13
14		var newUser api.NewUserRequest
15
16		err := c.ShouldBindJSON(&newUser)
17
18		if err != nil {
19			log.Printf("handler error: %v", err)
20			c.JSON(http.StatusBadRequest, nil)
21			return
22		}
23
24		err = s.userService.New(newUser)
25
26		if err != nil {
27			log.Printf("service error: %v", err)
28			c.JSON(http.StatusInternalServerError, nil)
29			return
30		}
31
32		response := map[string]string{
33			"status": "success",
34			"data":   "new user created",
35		}
36
37		c.JSON(http.StatusOK, response)
38	}
39}

We are going to accept a request with a JSON payload and using gin’s ShouldBindJSON method to extract the data. Go ahead and try it out!

It took some time to get here, so let me show you one of the benefits that I keep talking about: testability.

Create a file called user_test.go under the API folder and add the following:

 1package api_test
 2
 3import (
 4	"errors"
 5	"reflect"
 6	"testing"
 7	"weight-tracker/pkg/api"
 8)
 9
10type mockUserRepo struct{}
11
12func (m mockUserRepo) CreateUser(request api.NewUserRequest) error {
13	if request.Name == "test user already created" {
14		return errors.New("repository - user already exists in database")
15	}
16
17	return nil
18}
19
20func TestCreateNewUser(t *testing.T) {
21	mockRepo := mockUserRepo{}
22	mockUserService := api.NewUserService(&mockRepo)
23
24	tests := []struct {
25		name    string
26		request api.NewUserRequest
27		want    error
28	}{
29		{
30			name: "should create a new user successfully",
31			request: api.NewUserRequest{
32				Name:          "test user",
33				WeightGoal:    "maintain",
34				Age:           20,
35				Height:        180,
36				Sex:           "female",
37				ActivityLevel: 5,
38				Email:         "test_user@gmail.com",
39			},
40			want: nil,
41		}, {
42			name: "should return an error because of missing email",
43			request: api.NewUserRequest{
44				Name:          "test user",
45				Age:           20,
46				WeightGoal:    "maintain",
47				Height:        180,
48				Sex:           "female",
49				ActivityLevel: 5,
50				Email:         "",
51			},
52			want: errors.New("user service - email required"),
53		}, {
54			name: "should return an error because of missing name",
55			request: api.NewUserRequest{
56				Name:          "",
57				Age:           20,
58				WeightGoal:    "maintain",
59				Height:        180,
60				Sex:           "female",
61				ActivityLevel: 5,
62				Email:         "test_user@gmail.com",
63			},
64			want: errors.New("user service - name required"),
65		}, {
66			name: "should return error from database because user already exists",
67			request: api.NewUserRequest{
68				Name:          "test user already created",
69				Age:           20,
70				Height:        180,
71				WeightGoal:    "maintain",
72				Sex:           "female",
73				ActivityLevel: 5,
74				Email:         "test_user@gmail.com",
75			},
76			want: errors.New("repository - user already exists in database"),
77		},
78	}
79
80	for _, test := range tests {
81		t.Run(test.name, func(t *testing.T) {
82			err := mockUserService.New(test.request)
83
84			if !reflect.DeepEqual(err, test.want) {
85				t.Errorf("test: %v failed. got: %v, wanted: %v", test.name, err, test.want)
86			}
87		})
88	}
89}

A lot of things are happening here so let us go through them step-by-step.

We start by creating a struct called mockUserRepo. Our UserServiceknows that it needs a UserRepository with the following method: CreateUser. However, it does care about the actual implementation of said method, which allows us to mock the behaviour any way we want. In this case, we say that when the Name of the request is equal to “test user already created” (a bad name I know, but hopefully you get the point), return an error, and if not, just return nil. This allows us to mock the behaviour of our database to make it fit different situations, and test if our logic handles it in a way we expect it too.

Next, we create a new variable called mockRepo of type mockUserRepo We then create a mockUserService and pass the mockRepo to it, and we are ready to go! The actual test is what is known as a table-driven test. I won’t go into details about it as it is beyond the scope of this article, but if you want to know more, check out Dave Chaney’s article about it here.

Now, whenever we add a method to the UserRepository we would also have to add it here, in our mockUserRepo. We would of course also like to have some integration test, but what I really wanted to show with this article was how to do unit tests as these are cheap and easy to write.

Run go test ./... from the root directory and all tests should pass.

TDD implementation of Weight Service

Our application is not worth much right now. We can only create a user but not track our weight or calculate the amount of calories we need. Let’s change that!

Our application is not worth much right now. We can only create a user but not track our weight or calculate the number of calories we need. Let’s change that!I’m a big fan of test-driven development (TDD) and the way this application is structured makes it really easy to use. We are going to need three methods on our weight service: New, CalculateBMR and DailyIntake and two on our repository: CreateWeightEntry and GetUser. Open weight.go and add the following to WeightService and WeightRepository:

 1// weight.go
 2
 3// WeightService contains the methods of the user service
 4type WeightService interface {
 5	New(request NewWeightRequest) error
 6	CalculateBMR(height, age, weight int, sex string) (int, error)
 7	DailyIntake(BMR, activityLevel int, weightGoal string) (int, error)
 8}
 9
10type WeightRepository interface {
11	CreateWeightEntry(w Weight) error
12	GetUser(userID int) (User, error)
13}

Next, we need to add three structs to our definitions file: ´NewWeightRequest, Weight and User. Open up weight.go and add the following:

 1// add this after the package declaration
 2import "time"
 3
 4// add this below NewUserRequest
 5type User struct {
 6	ID            int       `json:"id"`
 7	CreatedAt     time.Time `json:"created_at"`
 8	UpdatedAt     time.Time `json:"updated_at"`
 9	Name          string    `json:"name"`
10	Age           int       `json:"age"`
11	Height        int       `json:"height"`
12	Sex           string    `json:"sex"`
13	ActivityLevel int       `json:"activity_level"`
14	WeightGoal    string    `json:"weight_goal"`
15	Email         string    `json:"email"`
16}
17
18type Weight struct {
19	Weight             int `json:"weight"`
20	UserID             int `json:"user_id"`
21	BMR                int `json:"bmr"`
22	DailyCaloricIntake int `json:"daily_caloric_intake"`
23}
24
25type NewWeightRequest struct {
26	Weight int `json:"weight"`
27	UserID int `json:"user_id"`
28}

Now, you will see an error on NewWeightService about missing methods. We don’t want to write the actual implementation yet, as we are doing TDD, so for now, just add this below NewWeightService:

 1// add these below NewWeightService
 2func (w *weightService) CalculateBMR(height, age, weight int, sex string) (int, error) {
 3	panic("implement me")
 4}
 5
 6func (w *weightService) DailyIntake(BMR, activityLevel int, weightGoal string) (int, error) {
 7	panic("implement me")
 8}
 9
10func (w *weightService) New(request NewWeightRequest) error {
11	panic("implement me")
12}

Open up main.go and you will see that we also have some missing methods on storage passed to api.NewWeightService. Open up storage.go and add these:

 1// add this to the Storage interface
 2type Storage interface {
 3	CreateWeightEntry(request api.Weight) error
 4	GetUser(userID int) (api.User, error)
 5}
 6
 7// add this below the CreateUser method
 8func (s *storage) CreateWeightEntry(request api.Weight) error {
 9	panic("implement me!")
10}
11
12func (s *storage) GetUser(userID int) (api.User, error) {
13	panic("implement me!")
14}

Lets now add the tests that we will need, create a file calledweight_test.go in the API folder and add the following:

  1package api_test
  2
  3import (
  4	"errors"
  5	"reflect"
  6	"testing"
  7	"weight-tracker/pkg/api"
  8)
  9
 10type mockWeightRepo struct{}
 11
 12func (m mockWeightRepo) CreateWeightEntry(w api.Weight) error {
 13	return nil
 14}
 15
 16func (m mockWeightRepo) GetUser(userID int) (api.User, error) {
 17	if userID != 1 {
 18		return api.User{}, errors.New("storage - user doesn't exists")
 19	}
 20
 21	return api.User{
 22		ID:            userID,
 23		Name:          "Test user",
 24		Age:           20,
 25		Height:        185,
 26		WeightGoal:    "maintain",
 27		Sex:           "female",
 28		ActivityLevel: 5,
 29		Email:         "test@mail.com",
 30	}, nil
 31}
 32
 33func TestCreateWeightEntry(t *testing.T) {
 34	mockRepo := mockWeightRepo{}
 35	mockUserService := api.NewWeightService(&mockRepo)
 36
 37	tests := []struct {
 38		name    string
 39		request api.NewWeightRequest
 40		want    error
 41	}{
 42		{
 43			name: "should create a new user successfully",
 44			request: api.NewWeightRequest{
 45				Weight: 70,
 46				UserID: 1,
 47			},
 48			want: nil,
 49		}, {
 50			name: "should return a error because user already exists",
 51			request: api.NewWeightRequest{
 52				Weight: 70,
 53				UserID: 2,
 54			},
 55			want: errors.New("storage - user doesn't exists"),
 56		},
 57	}
 58
 59	for _, test := range tests {
 60		t.Run(test.name, func(t *testing.T) {
 61			err := mockUserService.New(test.request)
 62			if !reflect.DeepEqual(err, test.want) {
 63				t.Errorf("test: %v failed. got: %v, wanted: %v", test.name, err, test.want)
 64			}
 65		})
 66	}
 67}
 68
 69func TestCalculateBMR(t *testing.T) {
 70	mockRepo := mockWeightRepo{}
 71	mockUserService := api.NewWeightService(&mockRepo)
 72
 73	tests := []struct {
 74		name   string
 75		Height int
 76		Age    int
 77		Weight int
 78		Sex    string
 79		want   int
 80		err    error
 81	}{
 82		{
 83			name:   "should calculate BMR for a female",
 84			Height: 170,
 85			Age:    22,
 86			Weight: 65,
 87			Sex:    "female",
 88			want:   1441,
 89			err:    nil,
 90		}, {
 91			name:   "should calculate BMR for a male",
 92			Height: 170,
 93			Age:    22,
 94			Weight: 65,
 95			Sex:    "male",
 96			want:   1607,
 97			err:    nil,
 98		}, {
 99			name:   "should return error because sex wasn't properly specified",
100			Height: 170,
101			Age:    22,
102			Weight: 65,
103			Sex:    "",
104			want:   0,
105			err:    errors.New("invalid variable sex provided to CalculateBMR. needs to be either male or female"),
106		},
107	}
108
109	for _, test := range tests {
110		t.Run(test.name, func(t *testing.T) {
111			BMR, err := mockUserService.CalculateBMR(test.Height, test.Age, test.Weight, test.Sex)
112			if !reflect.DeepEqual(err, test.err) {
113				t.Errorf("test: %v failed. got: %v, wanted: %v", test.name, err, test.want)
114			}
115
116			if !reflect.DeepEqual(BMR, test.want) {
117				t.Errorf("test: %v failed. got: %v, wanted: %v", test.name, BMR, test.want)
118			}
119		})
120	}
121}
122
123func TestDailyIntake(t *testing.T) {
124	mockRepo := mockWeightRepo{}
125	mockUserService := api.NewWeightService(&mockRepo)
126
127	tests := []struct {
128		name          string
129		BMR           int
130		ActivityLevel int
131		weightGoal    string
132		want          int
133		err           error
134	}{
135		{
136			name:          "should calculate daily intake for activity level 1 with weight loss as goal",
137			weightGoal:    "loose",
138			BMR:           1441,
139			ActivityLevel: 1,
140			want:          1229,
141			err:           nil,
142		}, {
143			name:          "should calculate daily intake for activity level 2 with weight loss as goal",
144			weightGoal:    "loose",
145			BMR:           1441,
146			ActivityLevel: 2,
147			want:          1481,
148			err:           nil,
149		}, {
150			name:          "should calculate daily intake for activity level 3 with weight loss as goal",
151			weightGoal:    "loose",
152			BMR:           1441,
153			ActivityLevel: 3,
154			want:          1733,
155			err:           nil,
156		}, {
157			name:          "should calculate daily intake for activity level 4 with weight increase as goal",
158			weightGoal:    "gain",
159			BMR:           1441,
160			ActivityLevel: 4,
161			want:          2985,
162			err:           nil,
163		}, {
164			name:          "should calculate daily intake for activity level 5 with weight maintenance as goal",
165			weightGoal:    "maintain",
166			BMR:           1441,
167			ActivityLevel: 5,
168			want:          2737,
169			err:           nil,
170		},
171	}
172
173	for _, test := range tests {
174		t.Run(test.name, func(t *testing.T) {
175			BMR, err := mockUserService.DailyIntake(test.BMR, test.ActivityLevel, test.weightGoal)
176			if !reflect.DeepEqual(err, test.err) {
177				t.Errorf("test: %v failed. got: %v, wanted: %v", test.name, err, test.want)
178			}
179
180			if !reflect.DeepEqual(BMR, test.want) {
181				t.Errorf("test: %v failed. got: %v, wanted: %v", test.name, BMR, test.want)
182			}
183		})
184	}
185}

The actual content of the tests is not so important as the fact that we can quickly write tests and mock external dependencies such as the methods to interact with the database.

Run go test ./... from the root directory and you should receive a lot of failed tests. We are going to fix this by implementing the logic of these methods, starting with the three in our WeightService. Add this below NewWeightService:

  1// add this after the package declaration
  2import "errors"
  3
  4// we define some activity multipliers here - these are just part of the
  5// formula I choose to calculate your daily caloric intake.
  6const (
  7	// Very Low Intensity activity multiplier - 1
  8	veryLowActivity = 1.2
  9	// Light exercise activity multiplier - 3-4x per week - 2
 10	lightActivity = 1.375
 11	// Moderate exercise activity multiplier - 3-5x per week 30-60 mins/session - 3
 12	moderateActivity = 1.55
 13	// High exercise activity multiplier - (6-7x per week for 45-60 mins) - 4
 14	highActivity = 1.725
 15	// Very high exercise activity multiplier - for people who is an Athlete - 5
 16	veryHighActivity = 1.9
 17)
 18
 19func (w *weightService) New(request NewWeightRequest) error {
 20	if request.UserID == 0 {
 21		return errors.New("weight service - user ID cannot be 0")
 22	}
 23
 24	user, err := w.storage.GetUser(request.UserID)
 25
 26	if err != nil {
 27		return err
 28	}
 29
 30	bmr, err := w.CalculateBMR(user.Height, user.Age, request.Weight, user.Sex)
 31
 32	if err != nil {
 33		return err
 34	}
 35
 36	dailyIntake, err := w.DailyIntake(bmr, user.ActivityLevel, user.WeightGoal)
 37
 38	if err != nil {
 39		return err
 40	}
 41
 42	newWeight := Weight{
 43		Weight:             request.Weight,
 44		UserID:             user.ID,
 45		BMR:                bmr,
 46		DailyCaloricIntake: dailyIntake,
 47	}
 48
 49	err = w.storage.CreateWeightEntry(newWeight)
 50
 51	if err != nil {
 52		return err
 53	}
 54
 55	return nil
 56}
 57
 58func (w *weightService) CalculateBMR(height, age, weight int, sex string) (int, error) {
 59	var sexModifier int
 60
 61	switch sex {
 62	case "male":
 63		sexModifier = -5
 64	case "female":
 65		sexModifier = 161
 66	default:
 67		return 0, errors.New("invalid variable sex provided to CalculateBMR. needs to be either male or female")
 68	}
 69
 70	return (10 * weight) + int(float64(height)*6.25) - (5 * age) - sexModifier, nil
 71}
 72
 73func (w *weightService) DailyIntake(BMR, activityLevel int, weightGoal string) (int, error) {
 74	var maintenanceCalories int
 75
 76	switch activityLevel {
 77	case 1:
 78		maintenanceCalories = int(float64(BMR) * veryLowActivity)
 79	case 2:
 80		maintenanceCalories = int(float64(BMR) * lightActivity)
 81	case 3:
 82		maintenanceCalories = int(float64(BMR) * moderateActivity)
 83	case 4:
 84		maintenanceCalories = int(float64(BMR) * highActivity)
 85	case 5:
 86		maintenanceCalories = int(float64(BMR) * veryHighActivity)
 87	default:
 88		return 0, errors.New("invalid variable activityLevel - needs to be 1, 2, 3, 4 or 5")
 89	}
 90
 91	var dailyCaloricIntake int
 92
 93	switch weightGoal {
 94	case "gain":
 95		dailyCaloricIntake = maintenanceCalories + 500
 96	case "loose":
 97		dailyCaloricIntake = maintenanceCalories - 500
 98	case "maintain":
 99		dailyCaloricIntake = maintenanceCalories
100	default:
101		return 0, errors.New("invalid weight goal provided - must be gain, loose or maintain")
102	}
103
104	return dailyCaloricIntake, nil
105}

With these methods added all of the tests should pass.

The last thing we need is to add the methods needed to interact with the database. Open storage.go and add the following:

 1// replace the methods: CreateWeightEntry and GetUser with the code below
 2func (s *storage) CreateWeightEntry(request api.Weight) error {
 3	newWeightStatement := `
 4		INSERT INTO weight (weight, user_id, bmr, daily_caloric_intake)
 5		VALUES ($1, $2, $3, $4)
 6		RETURNING id;
 7		`
 8
 9	var ID int
10	err := s.db.QueryRow(newWeightStatement, request.Weight, request.UserID, request.BMR, request.DailyCaloricIntake).Scan(&ID)
11
12	if err != nil {
13		log.Printf("this was the error: %v", err.Error())
14		return err
15	}
16
17	return nil
18}
19
20func (s *storage) GetUser(userID int) (api.User, error) {
21	getUserStatement := `
22		SELECT id, name, age, height, sex, activity_level, email, weight_goal FROM "user"
23		where id=$1;
24		`
25
26	var user api.User
27	err := s.db.QueryRow(getUserStatement, userID).Scan(&user.ID, &user.Name, &user.Age, &user.Height, &user.Sex, &user.ActivityLevel, &user.Email, &user.WeightGoal)
28
29	if err != nil {
30		log.Printf("this was the error: %v", err.Error())
31		return api.User{}, err
32	}
33
34	return user, nil
35}

We didn’t do this in true TDD style, however, it should again paint a picture of how we can structure Go applications and develop them using TDD. Feel free to re-implement this section and do it in true TDD style: create one test, make it pass, create the next one, and so on.

We have almost everything we need now. The last thing we need is to add routes and a handler to create a weight entry for a given user. I’m going to leave this up to the reader to implement, as the building blocks should be in place. If you are feeling lazy you can just check out the accompanying Github repository.

Conclusion

Hopefully, this tutorial gave you some insights on how to structure your Golang applications. It was a long one I know, but hopefully, you didn’t waste your time. In a part two, I will show how to add Makefile commands, integration tests and easily deploy the application. If you have any questions or criticisms, please do not hesitate to reach out.

My twitter and github.

Resources