Go Crash Course Pt. 3: Refactoring Our Code

May 11, 2021 23 min read

Go Crash Course Pt. 3: Refactoring Our Code

This is the third instalment of the Go Crash Course. In the first instalment, you learned how to create your first Go application. In the second instalment, you learned how to create a Go application that looked similar to one you'd encounter in the professional world. Today, we'll refactor and improve the code we wrote last time.

Separating the Application Logic

As a reminder, here's what our main.go file looked like:

package main

import (
	"encoding/json"
	"fmt"
	"net/http"
)

var (
	lightbulbs = make(map[string]bool)
)

func main() {
	lightbulbs["livingroom"] = false
	lightbulbs["kitchen"] = false

	http.HandleFunc("/healthcheck", func(responseWriter http.ResponseWriter, request *http.Request) {
		responseWriter.Header().Set("Content-Type", "application-json")
		responseWriter.WriteHeader(http.StatusOK)
		responseWriter.Write([]byte(`{"message":"service is up and running"}`))
	})

	http.HandleFunc("/lightbulbs", func(responseWriter http.ResponseWriter, request *http.Request) {
		responseWriter.Header().Set("Content-Type", "application-json")
		responseWriter.WriteHeader(http.StatusOK)
		json.NewEncoder(responseWriter).Encode(lightbulbs)
	})

	http.HandleFunc("/lightbulbs/switch", func(responseWriter http.ResponseWriter, request *http.Request) {
		responseWriter.Header().Set("Content-Type", "application-json")

		name := request.URL.Query().Get("name")
		if name == "" {
			responseWriter.WriteHeader(http.StatusBadRequest)
			responseWriter.Write([]byte(`{"message":"a light bulb name should be provided as the value of a 'name' querystring parameter"}`))
			return
		}

		if _, keyExists := lightbulbs[name]; !keyExists {
			responseWriter.WriteHeader(http.StatusNotFound)
			responseWriter.Write([]byte(`{"message":"a light bulb with the provided name doesn't exist"}`))
			return
		}

		lightbulbs[name] = !lightbulbs[name]

		responseWriter.WriteHeader(http.StatusOK)
		json.NewEncoder(responseWriter).Encode(lightbulbs)
	})

	http.HandleFunc("/lightbulbs/create", func(responseWriter http.ResponseWriter, request *http.Request) {
		responseWriter.Header().Set("Content-Type", "application-json")

		name := request.URL.Query().Get("name")
		if name == "" {
			responseWriter.WriteHeader(http.StatusBadRequest)
			responseWriter.Write([]byte(`{"message":"a light bulb name should be provided as the value of a 'name' querystring parameter"}`))
			return
		}

		if _, keyExists := lightbulbs[name]; keyExists {
			responseWriter.WriteHeader(http.StatusBadRequest)
			responseWriter.Write([]byte(`{"message":"a lightbulb with the provided name already exists"}`))
			return
		}

		lightbulbs[name] = false

		responseWriter.WriteHeader(http.StatusOK)
		json.NewEncoder(responseWriter).Encode(lightbulbs)
	})

	http.HandleFunc("/lightbulbs/delete", func(responseWriter http.ResponseWriter, request *http.Request) {
		responseWriter.Header().Set("Content-Type", "application-json")

		name := request.URL.Query().Get("name")
		if name == "" {
			responseWriter.WriteHeader(http.StatusBadRequest)
			responseWriter.Write([]byte(`{"message":"a light bulb name should be provided as the value of a 'name' querystring parameter"}`))
			return
		}

		if _, keyExists := lightbulbs[name]; !keyExists {
			responseWriter.WriteHeader(http.StatusNotFound)
			responseWriter.Write([]byte(`{"message":"a lightbulb with the provided name doesn't exist"}`))
			return
		}

		delete(lightbulbs, name)

		responseWriter.WriteHeader(http.StatusOK)
		json.NewEncoder(responseWriter).Encode(lightbulbs)
	})

	fmt.Println("http server listening on localhost:8080")
	http.ListenAndServe(":8080", nil)
}

While this code works, it's far from great. First off, we need to separate the application logic. As mentioned previously, we can group common functionalities under packages.

So let's create a folder called house (because we're automating our house). This will be our package for anything house-related. Inside this package, create a file called in_memory_storage.go. Add this code to the file:

package house

import "errors"

type InMemoryStorage struct {
	data map[string]bool
}

func (db *InMemoryStorage) GetAll() ([]Lightbulb, error) {
	lbs := []Lightbulb{}
	for k, v := range db.data {
		lbs = append(lbs, Lightbulb{Name: k, On: v})
	}

	return lbs, nil
}

func (db *InMemoryStorage) Get(name string) (Lightbulb, error) {
	if val, exists := db.data[name]; exists {
		return Lightbulb{Name: name, On: val}, nil
	}

	return Lightbulb{}, errors.New("key not found")
}

func (db *InMemoryStorage) Create(lb Lightbulb) error {
	db.data[lb.Name] = lb.On
	return nil
}

func (db *InMemoryStorage) Update(lb Lightbulb) error {
	db.data[lb.Name] = lb.On
	return nil
}

func (db *InMemoryStorage) Delete(name string) error {
	delete(db.data, name)
	return nil
}

func NewInMemoryStorage() *InMemoryStorage {
	return &InMemoryStorage{
		data: map[string]bool{},
	}
}

Remember the first few lines of the main.go file? It kind of acted like an in-memory database. We formalized that into our code here. To do so, we created a struct called InMemoryStorage, which will act as a database (that's why we added some common database operations to it) as long as the process is running.

Let's write a unit test for this code that can serve as an example for other tests. Inside the house folder, create a file named in_memory_storage_test.go and add this:

package house

import "testing"

func TestInMemoryStorage_Create(t *testing.T) {
	type fields struct {
		data map[string]bool
	}
	type args struct {
		lb Lightbulb
	}
	tests := []struct {
		name      string
		fields    fields
		args      args
		wantErr   bool
		wantCount int
	}{
		{
			name: "empty_data_should_have_one_entry_after_create",
			fields: fields{
				data: map[string]bool{},
			},
			args: args{
				lb: Lightbulb{
					Name: "livingroom",
				},
			},
			wantErr:   false,
			wantCount: 1,
		},
		{
			name: "if_one_item_exists_data_should_have_two_entries_after_create",
			fields: fields{
				data: map[string]bool{
					"bedroom": false,
				},
			},
			args: args{
				lb: Lightbulb{
					Name: "living-room",
				},
			},
			wantErr:   false,
			wantCount: 2,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			db := &InMemoryStorage{
				data: tt.fields.data,
			}
			if err := db.Create(tt.args.lb); (err != nil) != tt.wantErr {
				t.Errorf("InMemoryStorage.Create() error = %v, wantErr %v", err, tt.wantErr)
			}

			items, _ := db.GetAll()
			itemCount := len(items)

			if itemCount != tt.wantCount {
				t.Errorf("itemCount %d, wantCount %d", itemCount, tt.wantCount)
			}
		})
	}
}

Creating HTTP handlers

Now that we separated the application logic and created a unit test for it, let's move on to HTTP handlers. Inside the house folder, create a filed called lightbulb.go with the following content:

package house

import (
	"encoding/json"
	"fmt"
	"log"
	"net/http"
)

var badRequestResponse = []byte(`{"message":"bad request"}`)
var methodNotAllowedResponse = []byte(`{"message":"method not allowed"}`)
var notFoundResponse = []byte(`{"message":"lightbulb not found"}`)

type Storage interface {
	GetAll() ([]Lightbulb, error)
	Get(name string) (Lightbulb, error)
	Create(lb Lightbulb) error
	Update(lb Lightbulb) error
	Delete(name string) error
}

type Lightbulb struct {
	Name string `json:"name"`
	On   bool   `json:"on"`
}

func GetLightbulb(storage Storage) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		if r.Method != http.MethodGet {
			w.WriteHeader(http.StatusMethodNotAllowed)
			w.Write(methodNotAllowedResponse)
			return
		}

		name := r.URL.Query().Get("name")
		if name == "" {
			lightbulbs, err := storage.GetAll()
			if err != nil {
				msg := fmt.Sprintf("an error occurred while trying to getall lightbulbs: %v\n", err)
				log.Println(msg)
				w.WriteHeader(http.StatusInternalServerError)
				w.Write([]byte(fmt.Sprintf(`{"message": %s}`, msg)))
				return
			}

			response := map[string]bool{}
			for _, lightbulb := range lightbulbs {
				response[lightbulb.Name] = lightbulb.On
			}

			json.NewEncoder(w).Encode(response)
			return
		}

		lightbulb, err := storage.Get(name)
		if err != nil {
			msg := fmt.Sprintf("an error occurred while trying to get lightbulb: %v\n", err)
			log.Println(msg)
			w.WriteHeader(http.StatusInternalServerError)
			w.Write([]byte(fmt.Sprintf(`{"message": %s}`, msg)))
			return
		}

		json.NewEncoder(w).Encode(lightbulb)
	}
}

func CreateLightbulb(storage Storage) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		if r.Method != http.MethodPost {
			w.WriteHeader(http.StatusMethodNotAllowed)
			w.Write(methodNotAllowedResponse)
			return
		}

		if r.Body == nil {
			log.Println("create requires a request body")
			w.WriteHeader(http.StatusBadRequest)
			w.Write(badRequestResponse)
			return
		}

		var lightbulb Lightbulb
		err := json.NewDecoder(r.Body).Decode(&lightbulb)
		if err != nil {
			msg := fmt.Sprintf("an error occurred while trying to create lightbulb: %v\n", err)
			log.Println(msg)
			w.WriteHeader(http.StatusBadRequest)
			w.Write(badRequestResponse)
			return
		}

		err = storage.Create(lightbulb)
		if err != nil {
			msg := fmt.Sprintf("an error occurred while trying to create lightbulbs: %v\n", err)
			log.Println(msg)
			w.WriteHeader(http.StatusInternalServerError)
			w.Write([]byte(fmt.Sprintf(`{"message": %s}`, msg)))
			return
		}

		json.NewEncoder(w).Encode(lightbulb)
	}
}

func SwitchLightbulb(storage Storage) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		if r.Method != http.MethodPut {
			w.WriteHeader(http.StatusMethodNotAllowed)
			w.Write(methodNotAllowedResponse)
			return
		}

		name := r.URL.Query().Get("name")
		lightbulb, err := storage.Get(name)
		if err != nil {
			msg := fmt.Sprintf("an error occurred while trying to get lightbulb: %v\n", err)
			log.Println(msg)
			w.WriteHeader(http.StatusInternalServerError)
			w.Write([]byte(fmt.Sprintf(`{"message": %s}`, msg)))
			return
		}

		lightbulb.On = !lightbulb.On

		err = storage.Update(lightbulb)
		if err != nil {
			msg := fmt.Sprintf("an error occurred while trying to update lightbulbs: %v\n", err)
			log.Println(msg)
			w.WriteHeader(http.StatusInternalServerError)
			w.Write([]byte(fmt.Sprintf(`{"message": %s}`, msg)))
			return
		}

		lightbulbs, err := storage.GetAll()
		if err != nil {
			msg := fmt.Sprintf("an error occurred while trying to getall lightbulbs: %v\n", err)
			log.Println(msg)
			w.WriteHeader(http.StatusInternalServerError)
			w.Write([]byte(fmt.Sprintf(`{"message": %s}`, msg)))
			return
		}

		json.NewEncoder(w).Encode(lightbulbs)
	}
}

func DeleteLightbulb(storage Storage) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		if r.Method != http.MethodDelete {
			w.WriteHeader(http.StatusMethodNotAllowed)
			w.Write(methodNotAllowedResponse)
			return
		}

		name := r.URL.Query().Get("name")

		err := storage.Delete(name)
		if err != nil {
			msg := fmt.Sprintf("an error occurred while trying to delete lightbulbs: %v\n", err)
			log.Println(msg)
			w.WriteHeader(http.StatusInternalServerError)
			w.Write([]byte(fmt.Sprintf(`{"message": %s}`, msg)))
			return
		}
	}
}

Whew. There's a lot going on here. Let's dissect this step by step.

var badRequestResponse = []byte(`{"message":"bad request"}`)
var methodNotAllowedResponse = []byte(`{"message":"method not allowed"}`)
var notFoundResponse = []byte(`{"message":"lightbulb not found"}`)

type Storage interface {
	GetAll() ([]Lightbulb, error)
	Get(name string) (Lightbulb, error)
	Create(lb Lightbulb) error
	Update(lb Lightbulb) error
	Delete(name string) error
}

type Lightbulb struct {
	Name string `json:"name"`
	On   bool   `json:"on"`
}

This is where we created all the things we'll need. Firstly, we created some variables with fixed response values. We need to allocate these once, after which we can reuse them whenever we need.

Secondly, we created an interface called Storage that represents the storage our service will depend on. Testing becomes easier when our handlers depend on an interface, because we can mock our dependency.  

This also means we can create another storage implementation in the future. We can replace the InMemoryStorage with whatever we want without much impact on our code at all, because nothing will change for our handlers.

Then we created the Lightbulb struct to represent our domain entity and, after that, we created our HTTP handlers. We wrapped our handler generation with a function that accepts our dependency (anything that implements the Storage interface). Let's have a look at this Create handler:

func CreateLightbulb(storage Storage) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		if r.Method != http.MethodPost {
			w.WriteHeader(http.StatusMethodNotAllowed)
			w.Write(methodNotAllowedResponse)
			return
		}

		if r.Body == nil {
			log.Println("create requires a request body")
			w.WriteHeader(http.StatusBadRequest)
			w.Write(badRequestResponse)
			return
		}

		var lightbulb Lightbulb
		err := json.NewDecoder(r.Body).Decode(&lightbulb)
		if err != nil {
			msg := fmt.Sprintf("an error occurred while trying to create lightbulb: %v\n", err)
			log.Println(msg)
			w.WriteHeader(http.StatusBadRequest)
			w.Write(badRequestResponse)
			return
		}

		err = storage.Create(lightbulb)
		if err != nil {
			msg := fmt.Sprintf("an error occurred while trying to create lightbulbs: %v\n", err)
			log.Println(msg)
			w.WriteHeader(http.StatusInternalServerError)
			w.Write([]byte(fmt.Sprintf(`{"message": %s}`, msg)))
			return
		}

It's pretty straightforward code. The first thing the handler does is set up the response content-type and check the request method. After that, it checks if the request body was empty before it decodes the request body to our domain struct, checks for errors, and logs if it finds anything. Then, if all goes well, it encodes its response to the ResponseWriter.

Let's create a test for this handler to show how you can mock the Storage. Inside the house folder, create a file called lightbulb_test.go and add the following:

package house

import (
	"bytes"
	"errors"
	"net/http"
	"net/http/httptest"
	"testing"
)

type MockStorage struct {
	err error
	lb  Lightbulb
	lbs []Lightbulb
}

func (m *MockStorage) GetAll() ([]Lightbulb, error) {
	return m.lbs, m.err
}

func (m *MockStorage) Get(name string) (Lightbulb, error) {
	return m.lb, m.err
}

func (m *MockStorage) Create(lb Lightbulb) error {
	return m.err
}

func (m *MockStorage) Update(lb Lightbulb) error {
	return m.err
}

func (m *MockStorage) Delete(name string) error {
	return m.err
}

func TestCreateLightbulb(t *testing.T) {
	type args struct {
		storage Storage
		r       func() *http.Request
	}
	tests := []struct {
		name           string
		args           args
		wantStatusCode int
	}{
		{
			name: "create_returns_200_when_all_good",
			args: args{
				storage: &MockStorage{},
				r: func() *http.Request {
					req, _ := http.NewRequest(http.MethodPost, "/create", bytes.NewReader([]byte(`{"name":"livingroom"}`)))
					return req
				},
			},
			wantStatusCode: http.StatusOK,
		},
		{
			name: "create_returns_500_when_storage_misbehaves",
			args: args{
				storage: &MockStorage{
					err: errors.New("something's wrong"),
				},
				r: func() *http.Request {
					req, _ := http.NewRequest(http.MethodPost, "/create", bytes.NewReader([]byte(`{"name":"livingroom"}`)))
					return req
				},
			},
			wantStatusCode: http.StatusInternalServerError,
		},
		{
			name: "create_returns_400_when_request_body_is_invalid",
			args: args{
				storage: &MockStorage{},
				r: func() *http.Request {
					req, _ := http.NewRequest(http.MethodPost, "/create", nil)
					return req
				},
			},
			wantStatusCode: http.StatusBadRequest,
		},
	}
	for _, tt := range tests {
		t.Run(tt.name, func(t *testing.T) {
			handler := CreateLightbulb(tt.args.storage)
			w := httptest.NewRecorder()
			handler(w, tt.args.r())
			result := w.Result()
			if result.St

First off, we created the MockStorage struct. As mentioned in the previous Go Crash Course articles, anything that satisfies an interface can be used in its place. In order to turn MockStorage into a valid Storage, we need to create the same functions that the interface requires.

But there's a catch. We designed the struct to have properties that represent all the possible return types for the functions. The functions just return those properties. This allows us to create a MockStorage that returns exactly what we want it to, no matter what.

In the first testcase of this snippet of code, where we're testing for a response status code of 200 if everything goes to plan, we created a MockStorage that has no error.

In the second testcase, where we're testing for a response status code of 500 if something bad happens while trying to access the storage, we created a MockStorage that returns error for all its operations.

In the third testcase, where we're testing for a response status code of 400 if we get a request with an invalid body, we created a MockStorage that's just fine, but a request that has an empty body.

A Refactored File

The composition root is the part that glues all this together. The main.go file should have its environment, dependencies, and wiring set up. Someone reading your codebase should be able to tell what your services depend on and how those dependencies are configured. So here's our refactored main.go file:

package main

import (
	"fmt"
	"log"
	"net/http"
	"time"

	"github.com/julianosouza/go-crash-course/house"
)

func main() {
	s := house.NewInMemoryStorage()
	router := http.NewServeMux()

	router.HandleFunc("/healthcheck", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application-json")
		w.WriteHeader(http.StatusOK)
		w.Write([]byte(`{"message":"service is up and running"}`))
		return
	})

	router.HandleFunc("/lightbulbs", house.GetLightbulb(s))
	router.HandleFunc("/lightbulbs/create", house.CreateLightbulb(s))
	router.HandleFunc("/lightbulbs/switch", house.SwitchLightbulb(s))
	router.HandleFunc("/lightbulbs/delete", house.DeleteLightbulb(s))

	srv := http.Server{
		Addr:         ":8080",
		WriteTimeout: 1 * time.Second,
		ReadTimeout:  1 * time.Second,
		Handler:      router,
	}

	fmt.Println("http server listening on localhost:8080")
	log.Fatal(srv.ListenAndServe())
}

First off, we create our storage. In this case, we're using the InMemoryStorage implementation. Then, we create a new router and set up the HTTP handlers.

router.HandleFunc("/lightbulbs", house.GetLightbulb(s))

Notice how our handler bindings are much more readable. At a glance, we're able to see that whenever someone hits /lightbulbs path, we're going to use the GetLightbulbs handler for the request that depends on a Storage implementation.

The final bit is where set up our HTTP server and let it run.

Exercise: play around with the code for a while. Implement some unit tests for the storage and the handler. Try to create a new implementation for the Storage interface that allows you to handle a real database. Redis is a good, simple option for this. Try to create a RedisStorage if you're up for a challenge.


This was the third instalment of our Go Crash Course. In the fourth instalment, we'll learn about logging, monitoring, and building containers. See you there!

SHARE:

arrow_upward