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
- 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
- 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"`
}
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")
}
}
Running this straight away using
go test
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
}
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")
}
}
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
}
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
}
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
}
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
}
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:
- 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)
- endpoint.go endpoint holds the actual implementations of how each endpoint should be actioned
- main.go main is the dirtiest of all classes. It builds all the required objects used for dependency injection
- 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)