March 1st, 2024
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.
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.
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:
In go, such related information would go into a struct.
type Task struct {
Title string
Description string
}
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
}
We will be following the Basic Package structure initially
make a directory for your project
mkdir todo
initialize a go module inside your project
cd todo
go mod init nesh.me/todo
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
}
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.
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)
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
}
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
}
func (myList *MyTodoList) Print() error {
for _, t := range myList.Tasks {
fmt.Printf("%s: %s\n", t.Title, t.Description)
}
return nil
}
func (myList *MyTodoList) Length() int {
return len(myList.Tasks)
}
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:
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
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)
}
}
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)
}
}
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)
}
}
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)
}
}
Run the tests to make sure everything is correct:
go test
Results:
PASS
ok nesh.me/todo 0.002s
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: