Mohamed Alnashmi

Home

A Clean Domain Driven Golang Introduction

March 1st, 2024

Table of Content

Summary

  1. This is not meant to be the most effecient code
  2. Define your business & domain using structs and interfaces
  3. Create a struct that will implement the business logic
  4. Use typecasting to make sure your struct implements the interface correctly
  5. Use pointer receivers if you intend to modify things inside the struct.
  6. Make sure you test your implementation

Prerequsities

Quick summary

We will be building a simple todo app, gradually adding to it different technologies and capabilities.

In this short tutorial, we will setup the project, define the business domain, define the interfaces and then implement the functionality.

Effeciency is not the main goal here, and we're not following DDD or Clean Architecture strictly.

The domain & business logic

What can our todo package actually provide for the user? What are the business benefits? These answers & functinalities should be prominent and easily discovered.

It's a list of tasks you need to complete or things that you want to do. Most typically, they're organised in order of priority.

From that definition, some basic functionality we need: create a task, add it to a list, set its priority and mark it as done once completed.

What is a task?

Before we start creating our todo list, lets define a task. According to the definition above: A "thing" you want to do. So most likely it has a title, a description, maybe an completion or target date and a priority?

Some examples:

  1. Clean dishes: help the wife clean the dishes after dinner.
  2. Go to Dentist: schedule a dental checkup
  3. Write first blog!!: prepare first blog for the golang todo-series.

In go, such related information would go into a struct.

type Task struct {
	Title       string
	Description string
}

What can our List do?

From the quick google search, we should be able to:

create a task, mark it as done and print the list of tasks.

Capabilities of business features are easiest expressed in interfaces.

To represent a Task we used struct allowing us to collect information regarding a "thing we want to do". interfaces allows us to define set of actions that can be performed.

A struct is for information, an interface is for actions

To describe the actions our todo list will be able to provide:

type TodoList interface {
		CreateTask(title string, description string) (Task, error)
		CompleteTask(title string) error
		Print() error
		Length() int
}

Implementation

Setting up the project

We will be following the Basic Package structure initially

  1. make a directory for your project

     mkdir todo
  2. initialize a go module inside your project

    cd todo
    go mod init nesh.me/todo
  3. Create your first file: todo.go

    touch todo.go

    Your todo.go should have the domain definitions explained above, it should look like this:

    package todo
    
    type Task struct {
    	Title       string
    	Description string
    }
    
    type TodoList interface {
    	CreateTask(title string, description string) (Task, error)
    	CompleteTask(title string) error
    	Print() error
    	Length() int
    }

Implementing the interface

interfaces in go provides no functionality and only definitions. We have to create an object/type that implements this interface.

Interfaces are implemented implicitly. Unlike other languages you do not need to explicitly state that an object implements an interface (eg. java). If a type implements all the methods in the interface, then its said to implement the interface.

Lets start with creating a struct that will be the type that implements our interface:

type MyTodoList struct {}

To make our new type MyTodoList compatible with our TodoList business domain interface, we have to implement the methods as follows:

func (myList *MyTodoList) CreateTask(title string, description string) (Task, error) {
	return Task{}, errors.New("not implemented yet")
}
	
func (myList *MyTodoList) CompleteTask(title string) error {
	return errors.New("not implemented yet")
}

func (myList *MyTodoList) Print() error {
	return errors.New("not implemented yet")
}

func (myList *MyTodoList) Length() int {
	return 0
}

Because we ultimately have to change the list by adding and removing items, we need to be able to modify the MyTodoList. This means that the receiver should be a pointer rather than a value receiver. Read more

Methods with pointer receivers can modify the value to which the receiver points (as Scale does here). Since methods often need to modify their receiver, pointer receivers are more common than value receivers.

Implementing the functionality

Its clear that we will need a place to store the tasks. Lets add that in first to our MyTodoList. We will use a slice of Task object.

type MyTodoList struct {
	Tasks []Task
}

Now we have a place where we can store our tasks.

A nice line of code that ensures your struct actually implements the interface youve created.

var _ TodoList = (*MyTodoList)(nil)

Creating a Task

func (myList *MyTodoList) CreateTask(title string, description string) (Task, error) {
	//create the task
	newTask := Task{title, description}
	//store the task in our list
	myList.Tasks = append(myList.Tasks, newTask)
	//return the task and indicate no error occurred
	return newTask,nil
}

Completing and Removing a Task

Make sure you import the fmt package.

import "fmt"

func (myList *MyTodoList) CompleteTask(title string) error {
	//find the task - ofcourse there are better algorithms here
	taskIndex := -1
	for i, t := range myList.Tasks {
		if t.Title == title {
			taskIndex = i
			break
		}
	}

	if taskIndex == -1 {
		return fmt.Errorf("task '%s' not found on the list", title)
	}

	//remove the item from the slice
	myList.Tasks = append(myList.Tasks[:taskIndex], myList.Tasks[taskIndex+1:]...)

	return nil
}

Printing our Tasks List

func (myList *MyTodoList) Print() error {
	for _, t := range myList.Tasks {
		fmt.Printf("%s: %s\n", t.Title, t.Description)
	}

	return nil
}

Checking how many tasks we have

func (myList *MyTodoList) Length() int {
		return len(myList.Tasks)
}

Testing it

Lets make sure our implementation is correct. In go, tests are placed in separate files with a suffix of _test.go. For our todo, we will need to create todo_test.go.

We will be testing the following:

  1. can i add a new task?
  2. what happens if i add the same task?
  3. can i complete a task?
  4. what happens if i complete a task that doesnt exist?

Start with the function definitions:

package todo
	
import "testing"

func TestAddTask(t *testing.T) {
	t.Errorf("not implemented")
}

func TestAddDuplicateTask(t *testing.T) {
	t.Errorf("not implemented")
}

func TestCompleteTask(t *testing.T) {
	t.Errorf("not implemented")
}

func TestCompleteNonExistingTask(t *testing.T) {
	t.Errorf("not implemented")
}

Run the tests to make sure everything is wired up:

go test

You should see the following output

--- FAIL: TestAddTask (0.00s)
	   todo_test.go:6: not implemented
	--- FAIL: TestAddDuplicateTask (0.00s)
	   todo_test.go:10: not implemented
	--- FAIL: TestCompleteTask (0.00s)
	   todo_test.go:14: not implemented
	--- FAIL: TestCompleteNonExistingTask (0.00s)
	   todo_test.go:18: not implemented
	FAIL
	exit status 1
	FAIL    nesh.me/todo       0.002s

Testing adding a new task

func TestAddTask(t *testing.T) {
	list := MyTodoList{}
	if _, err := list.CreateTask("new task", "first task"); err != nil {
		t.Error(err)
		return
	}

	numTasks := list.Length()
	if numTasks != 1 {
		t.Errorf("task was not added: %d tasks found, expecting 1", numTasks)
	}
}

Testing adding a duplicate task

We will allow it. Feel free to change the implementation. Try to mark a task now as complete, which one will be completed?

func TestAddDuplicateTask(t *testing.T) {
	list := MyTodoList{}
	if _, err := list.CreateTask("new task", "first task"); err != nil {
		t.Error(err)
		return
	}

	if _, err := list.CreateTask("new task", "duplicate task created"); err != nil {
		t.Error(err)
		return
	}

	numTasks := list.Length()
	if numTasks != 2 {
		t.Errorf("task was not added: %d tasks found, expecting 2", numTasks)
	}
}

Testing completing a task

func TestCompleteTask(t *testing.T) {
	list := MyTodoList{}
	if _, err := list.CreateTask("new task", "first task"); err != nil {
		t.Error(err)
		return
	}

	if err := list.CompleteTask("new task"); err != nil {
		t.Error(err)
		return
	}

	numTasks := list.Length()
	if numTasks != 0 {
		t.Errorf("task was not removed: %d tasks found, expecting 0", numTasks)
	}
}

Testing completing a non-existing task

func TestCompleteNonExistingTask(t *testing.T) {
	list := MyTodoList{}
	if _, err := list.CreateTask("new task", "first task"); err != nil {
		t.Error(err)
		return
	}

	//notice we're checking for error here. 
	//err should not be nil
	if err := list.CompleteTask("missing task"); err == nil {
		t.Error("missing task was completed without an error")
		return
	}

	//no changes shouldve been made to the list
	numTasks := list.Length()
	if numTasks != 1 {
		t.Errorf("task not found: %d tasks found, expecting 1", numTasks)
	}
}

Running the final tests

Run the tests to make sure everything is correct:

go test

Results:

PASS
	ok      nesh.me/todo       0.002s

Whats Next

This post will be built upon in future posts to enhance the features and eventually become a fully fledged tutorial. Not sure when, but the next steps are:

  1. Improve the implementation
  2. Use BDD and Cucumber for tests.
  3. Introduce directories to follow a "cleanish" architecture setup
  4. Introduce new methods of storage for the list (inmem, db)
  5. Introduce ports/interfaces for the service (http server & CLI)