DEV Community

James Eastham
James Eastham

Posted on

Project Structure and Test Driven Development in GoLang

I find a well-structured project helps massively as an application grows in scope and complexity.

Whilst for small projects like this it may not be too relevant, building a habit requires constant practice and refinement so I try to follow the same practices wherever possible.

Mono vs Separate Repositories

Ordinarily, I'm a huge fan of holding separate services in completely separate repositories.

It makes build pipelines a little less complex (no need to monitor paths) and it keeps clearly defined lines between each service context.

However, for the purposes of this series, I'm going to run with a single mono-repo. There are a couple of reasons for this

  1. This whole project is open source, so for anybody wanting to run the app locally having one single git clone command saves everybody a lot of hassle
  2. I've always stuck to the idea of separate repositories, but never but any real effort into a mono-repo and understanding the benefits that come with that.

Basic Repository Structure

So, I plan on my structure being something like this.

+-- README.md
+-- docker-compose.yml
+-- docs contains any documentation about the entire project
+-- src
| +-- team-service
| +-- | +-- domain contains the domain entities
| +-- | +-- usecases contains the domain use cases
| +-- | +-- interfaces contains the interfaces into the domain
| +-- | +-- infrastructure contains any infrastructure code (HTTP/Database etc)
| +-- | +-- docs contains service-specific documentation
| +-- fixture-service

This may change and expand over time, but having a clearly defined starting point should help in the long run. For both my own sanity and for other people looking at the repo.

My ideas for project layout are combined from Uncle Bob Martin's book on Clean Architecture and from this git hub repo that details Go best practices

Test-Driven Development

As always, I am going to try to follow the best TDD practices when developing all of these services.

Whilst this probably adds to the learning curve of Go, building these best practices from the start will form a solid backbone of my GoLang knowledge.

Getting started

It seemed logical, that the best place to start with my application was with the team-service.

Without teams, the whole system doesn't really have much use.

The team-service in it's most basic form is just a CRUD API wrapper around a data store of some kind. Sticking with my plan of using new technologies and being cloud-ready, I'm going to use Amazon DynamoDB as my data store (ADR here).

On investigating the usage of Go for a micro-service based application, GoKit seemed a really good fit so that will be the base of my program structure (ADR Here).

Teams

Entities

So the base of any data store type API is the stored model itself. Teams are the base aggregate of any data object.

Teams will have players, but players cannot be their own entities as far as the team service is concerned. So that gives a reasonably simple base model of

// Team is the central class in the domain model.
type Team struct {
    ID      string             `json:"id"`
    Name    string             `json:"teamName"`
    Players map[string]*Player `json:"players"`
}

// Player holds data for all the players that a team has
type Player struct {
    Name     string `json:"name"`
    Position string `json:"position"`
}
Enter fullscreen mode Exit fullscreen mode

I fully expect the object properties to expand over time, but as a basic functional model this is fine.

Let's get some tests together

So, let's write a test for creating a new team and then adding some players to the team.

To begin with, I add the following test to the domain_test.go file. When I load a team from the database, I want to be able to add a player to that team.

func TestCanAddValidPlayerToTeam(t *testing.T) {
    team := &Team{}

    team.AddPlayer(&Player{
        Name:     "James",
        Position: "GK",
    })

    if len(team.Players) < 1 {
        t.Fatalf("Player not added")
    }
}
Enter fullscreen mode Exit fullscreen mode

Running this straight away using

go test
Enter fullscreen mode Exit fullscreen mode

Throws an error as that method has not yet been implemented. I can then get rid of the errors by adding the following code to the team.go file.

// AddPlayerToTeam adds a new player to the specified team.
// AddPlayer adds a player to a team.
func (team *Team) AddPlayer(player *Player) error {
    team.Players = append(team.Players, player)

    return nil
}
Enter fullscreen mode Exit fullscreen mode

We also need to check to see if a player exists already within the team and if they do just return that player instead of adding a duplicate. To check this, I will add one more test.

func TestCanAddValidPlayerToTeam_DuplicatePlayer_ShouldThrowError(t *testing.T) {
    team := &Team{}

    firstPlayerAddResult := team.AddPlayer(&Player{
        Name:     "James Eastham",
        Position: "GK",
    })

    secondPlayerAddResult := team.AddPlayer(&Player{
        Name:     "James Eastham",
        Position: "GK",
    })

    if firstPlayerAddResult != nil || secondPlayerAddResult == nil {
        t.Fatalf("Second add of the same name should throw an error")
    }
}
Enter fullscreen mode Exit fullscreen mode

Running the tests returns one failure with a message of "Duplicate player has been added".

To get around this, I will then update my AddPlayerToTeam method to be

// AddPlayerToTeam adds a new player to the specified team, if the player exists the existing player is returned.
func (team *Team) AddPlayerToTeam(playerName string, position string) (*Player, error) {
    for _, v := range team.Players {
        if v.Name == playerName {
            return v, nil
        }
    }

    player := &Player{
        Name:     playerName,
        Position: position,
    }

    team.Players = append(team.Players, player)

    return player, nil
}
Enter fullscreen mode Exit fullscreen mode

From there, I added quite a few test methods for validating different parts of the player object that has been added. This leaves the domain.go file looking like this.

package domain

import "errors"

var validPositions = [...]string{
    "GK",
    "DEF",
    "MID",
    "ST",
}

// ErrInvalidArgument is thrown when a method argument is invalid.
var ErrInvalidArgument = errors.New("Invalid argument")

// TeamRepository handles the persistence of teams.
type TeamRepository interface {
    Store(team Team)
    FindById(id string) Team
    Update(team Team)
}

// PlayerRepository repository handles the persistence of players.
type PlayerRepository interface {
    Store(player Player)
    FindById(id string) Player
    Update(player Player)
}

// Team is a base entity.
type Team struct {
    ID      string    `json:"id"`
    Name    string    `json:"teamName"`
    Players []*Player `json:"players"`
}

// Player holds data for all the players that a team has.
type Player struct {
    Name     string `json:"name"`
    Position string `json:"position"`
}

// AddPlayer adds a player to a team.
func (team *Team) AddPlayer(player *Player) error {

    if len(player.Name) == 0 {
        return ErrInvalidArgument
    }

    for _, v := range team.Players {
        if v.Name == player.Name {
            return ErrInvalidArgument
        }
    }

    if len(player.Position) == 0 {
        return ErrInvalidArgument
    }

    isPositionValid := false

    for _, v := range validPositions {
        if v == player.Position {
            isPositionValid = true
            break
        }
    }

    if isPositionValid == false {
        return ErrInvalidArgument
    }

    team.Players = append(team.Players, player)

    return nil
}
Enter fullscreen mode Exit fullscreen mode

Use Cases

One of the simplest use cases for the domain is the creation of a new team. Sticking with our principles of TDD, let's write a test to do just that.

func TestCanCreateTeam(t *testing.T) {
    teamInteractor := createInMemTeamInteractor()

    team := &CreateTeamRequest{
        Name: "Cornwall FC",
    }

    createdTeamID := teamInteractor.Create(team)

    if len(createdTeamID) == 0 {
        t.Fatalf("Team has not been created")
    }
}

func createInMemTeamInteractor() *TeamInteractor {
    teamInteractor := &TeamInteractor{
        TeamRepository: &mockTeamRepository{},
    }

    return teamInteractor
}
Enter fullscreen mode Exit fullscreen mode

Let's make this test pass

// Team holds a reference to the team data.
type CreateTeamRequest struct {
    Name string
}

// CreateTeam creates a new team in the database.
func (interactor *TeamInteractor) CreateTeam(team *CreateTeamRequest) (string, error) {
    newTeam := &domain.Team{
        Name: team.Name,
    }

    createdTeamID := interactor.TeamRepository.Store(newTeam)

    return createdTeamID, nil
}
Enter fullscreen mode Exit fullscreen mode

Nice and simple, we map the use cases CreateTeamRequest struct to the domain version of Team. Now it may seem a little excessive to have two completely separate objects with identical properties. But sticking to the 'rules' of CleanArchitecture the use cases layer uses having separate types removes any kind of coupling.

Encapsulating the business logic

Between the entity and use case layers, that gives me everything I need to build out the application.

As Bob Martin himself would say, everything else is just a detail.

I firmly believe that if the principles of clean architecture are followed properly, a huge amount of an application should be able to be built without considering databases, web services, HTTP or any external frameworks.

That said, because I'm impatient and I'm trying to learn Go whilst also sticking to best practices I have gone ahead and added a REST layer.

HTTP Layer

There are four files that give an extremely basic HTTP server. They are:

  1. transport.go transport holds details on the endpoints themselves, and the translation of the inbound request to something the service can understand (parsing request bodies, etc)
  2. endpoint.go endpoint holds the actual implementations of how each endpoint should be actioned
  3. main.go main is the dirtiest of all classes. It builds all the required objects used for dependency injection
  4. infrastructure/repositories.go repositories holds a very rudimentary in memory 'database'. On initialization, an empty array of team objects is created and used to hold any inbound requests

So there we have a very simple implementation of the team-service with some basic HTTP functions.

Over the next week, I'm going to be building out the internals of the team-service whilst trying to stay away from the detail for as long as possible.

This is a huge learning journey from me (Go is very different to C#) so if anybody picks up on anything I could be doing better I'd really appreciate the input.

Top comments (0)