- Create a module with
go mod initand split code into packages - Use standard packages such as
strings,strconv,osandtime - Write and run table-driven tests with
go test - Build an HTTP server that speaks JSON with
net/httpandencoding/json
You now know almost all of Go's core features. Let's bring them together in a real project: we will create our own module, split the code into packages, write tests and build a small REST API server that stores a to-do list. And Go's standard library alone will be enough for all of it.
Modules and packages
A module is a project with a go.mod file. go mod init example.com/tasks creates the module; example.com/tasks is the module path, usually the address where the code lives, such as github.com/aysel/tasks. Inside the module every folder is a package, imported as module path + folder name: example.com/tasks/store. From another package, only names starting with a capital letter are visible.
tasks/
├── go.mod
├── main.go
└── store/
├── store.go
└── store_test.gomodule example.com/tasks
go 1.22go line shows the minimum required version; yours may show a newer one.| Command | What it does |
|---|---|
go mod init example.com/tasks | creates a new module |
go get golang.org/x/text | adds a third-party package and records it in go.mod |
go mod tidy | adds missing dependencies and removes unused ones |
go build ./... | builds every package in the module (./... means this folder and all subfolders) |
go vet ./... | looks for suspicious code: bad format verbs, copied mutexes |
The standard library
Go's standard library is very rich: text, numbers, files, time, JSON, an HTTP server and client, cryptography, testing — all of it comes with the language. That is why many small and medium projects are written without any third-party dependencies at all.
| Package | What for |
|---|---|
fmt | formatted printing and building strings |
strings, strconv | working with strings, string ↔ number conversion |
os | files, environment variables, arguments |
time | dates, times and durations |
encoding/json | encoding values to JSON and decoding JSON |
net/http | HTTP server and client |
testing | automated tests |
package main
import (
"fmt"
"strconv"
"strings"
"time"
)
func main() {
fmt.Println(strings.Split("go,java,sql", ","))
fmt.Println(strings.Repeat("ab", 3), strings.HasPrefix("golang", "go"))
n, err := strconv.ParseFloat("2.5", 64)
fmt.Println(n*2, err)
d := 90 * time.Minute
fmt.Println(d, d.Hours())
day := time.Date(2026, time.September, 27, 0, 0, 0, 0, time.UTC)
fmt.Println(day.Weekday(), day.Format("02.01.2006"))
}[go java sql] ababab true 5 <nil> 1h30m0s 1.5 Sunday 27.09.2026
Project: the store package and tests
We move the logic that stores tasks into a separate store package. Tags such as json:"id" on the fields of Task tell the encoding/json package which names to use in JSON. The zero value of Store is ready to use straight away — no constructor needed. This is a favourite Go principle: “make the zero value useful”.
package store
import (
"errors"
"strings"
"sync"
)
var ErrEmptyTitle = errors.New("empty title")
type Task struct {
ID int `json:"id"`
Title string `json:"title"`
}
type Store struct {
mu sync.Mutex
tasks []Task
}
func (s *Store) Add(title string) (Task, error) {
title = strings.TrimSpace(title)
if title == "" {
return Task{}, ErrEmptyTitle
}
s.mu.Lock()
defer s.mu.Unlock()
t := Task{ID: len(s.tasks) + 1, Title: title}
s.tasks = append(s.tasks, t)
return t, nil
}
func (s *Store) All() []Task {
s.mu.Lock()
defer s.mu.Unlock()
out := make([]Task, len(s.tasks))
copy(out, s.tasks)
return out
}All returns a copy of the internal slice, not the slice itself, so the caller cannot change the list outside the mutex. A test file's name ends in _test.go, and it sits in the same folder as the package under test. A test function starts with the word Test and takes a *testing.T: t.Errorf records a failure and carries on, while t.Fatalf stops the test immediately. In Go the most common style is table-driven tests: the cases are listed in a slice of structs, and one loop runs each of them as a separate subtest with t.Run.
package store
import (
"errors"
"testing"
)
func TestAdd(t *testing.T) {
tests := []struct {
name string
title string
want string
wantErr error
}{
{"simple", "Buy bread", "Buy bread", nil},
{"trims spaces", " Learn Go ", "Learn Go", nil},
{"empty", " ", "", ErrEmptyTitle},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var s Store
got, err := s.Add(tt.title)
if !errors.Is(err, tt.wantErr) {
t.Fatalf("Add(%q) error = %v, want %v", tt.title, err, tt.wantErr)
}
if got.Title != tt.want {
t.Errorf("Add(%q).Title = %q, want %q", tt.title, got.Title, tt.want)
}
})
}
}$ go test ./...
? example.com/tasks [no test files]
ok example.com/tasks/store 0.004sgo test -v ./store lists each subtest (TestAdd/trims_spaces — spaces become underscores), and go test -run TestAdd/empty ./store runs just one case.if got.Title != "Learn Go" {
t.Error("wrong title")
}if got.Title != tt.want {
t.Errorf("Add(%q).Title = %q, want %q",
tt.title, got.Title, tt.want)
}got X, want Y shape: when a test fails, you see why without opening the code.The HTTP server: net/http
In main.go, http.NewServeMux() creates a router. Since Go 1.22 you can put the HTTP method right into the route: "GET /tasks" and "POST /tasks". Every handler takes two parameters: an http.ResponseWriter for writing the response and the incoming *http.Request. json.NewEncoder(w).Encode(...) turns a value into JSON and writes it to the response, and json.NewDecoder(r.Body).Decode(&in) reads the request body. The port comes from the PORT environment variable (os.Getenv), with 8080 as the fallback.
package main
import (
"encoding/json"
"log"
"net/http"
"os"
"example.com/tasks/store"
)
func main() {
var tasks store.Store
mux := http.NewServeMux()
mux.HandleFunc("GET /tasks", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(tasks.All())
})
mux.HandleFunc("POST /tasks", func(w http.ResponseWriter, r *http.Request) {
var in struct {
Title string `json:"title"`
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
http.Error(w, "invalid JSON", http.StatusBadRequest)
return
}
task, err := tasks.Add(in.Title)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(task)
})
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
log.Println("listening on :" + port)
log.Fatal(http.ListenAndServe(":"+port, mux))
}Start the server with go run . and send requests from another terminal with curl. POST creates a new task and returns JSON with the status 201 Created, an empty title gives 400 Bad Request, and GET returns the whole list. If you send a request with a method that has no route (for example DELETE /tasks), ServeMux answers 405 Method Not Allowed by itself.
$ curl -X POST localhost:8080/tasks -d '{"title":"Learn Go"}'
{"id":1,"title":"Learn Go"}
$ curl -X POST localhost:8080/tasks -d '{"title":" "}'
empty title
$ curl localhost:8080/tasks
[{"id":1,"title":"Learn Go"}]Key points
go mod initcreates a module; every folder is a package, imported asmodule path/folder.- The standard library (
fmt,strings,strconv,os,time,encoding/json,net/http) covers most work without third-party dependencies. - Tests are
TestXxx(t *testing.T)functions in_test.gofiles; table-driven tests witht.Runare the Go style. - Since Go 1.22,
ServeMuxsupports routes with methods ("GET /tasks") and path parameters ({id}). - Handlers run concurrently, so protect shared state with a mutex.
Check yourself
10 questions. Every correct answer earns XP.
example.com/tasks and the package lives in the store folder. How do you import it?