Clean Architecture in Go
There are many different ways to structure your code when developing a Go application. While it is possible to write code that works without following any particular structure, having a well-defined architecture can make your codebase much easier to maintain and scale as your application grows. Another advantage, that is worth mentioning, is that new developers can more easily understand the codebase and contribute faster with a well-defined architecture.
One popular approach is Clean Architecture, which is a software design philosophy that aims to make your codebase more maintainable and testable. In different programming languages, there are different ways to implement it and Golang is no exception.
All the code examples in this article are available in the repository.
What is Clean Architecture?
Let’s start by defining what Clean Architecture is.
Clean Architecture is a set principles and patterns that help you design your codebase in a reusable, testable, and maintainable way. It is based on the idea of separating your code into different layers, each with a specific responsibility.
There are several different ways of doing this, but one common approach is to define the following layers:
- Entity or Domain layer: These are the core domain entities of your
application. It represent the data that your application works with and are
independent of any other layer. Some examples of entities might be a
User
or aPost
. - Repository or Storage layer: These are the interfaces that your application uses to interact with external data sources, such as databases or APIs. In other words, every time you need to store or retrieve data, you should do it through a repository.
- Services or Use Cases: This includes all the business logic of your
application. It contain the rules and operations that your application performs.
For example, a use case might be
CreateUser
orGetPosts
. Services can be depedant on theRepository
andEntity
layers, or other services. - Controllers or Adapters: These are the interfaces between your application and the outside world. They handle input and output, such as HTTP requests and responses. You can have different controllers for the same service (eg you’re exposing both REST API and a GraphQL API).
Here is a diagram that illustrates the relationship between these layers:
Each next layer can depend on the previous one, but not the other way around.
For example, this means that the Entity
layer should not depend on any other
layer in the application, while the Repository
layer can depend on the
Entity
, Services
can depend on Repository
and Entity
, and so on.
This separation of concerns makes it easier to test and maintain your codebase. While it may seem like a lot of work to set up all these layers, it can save you a lot of time in the long run by making your code more modular and easier to maintain.
In this tutorial, we’re going to use a simplified model that assumes you have a shared domain models between all the layers. Usually, you would have separate struct since each layer can have its own representation of the data.
Developing a Clean Architecture in Golang
We’re going to build a simple application that demonstrates how to implement Clean Architecture in Golang. The application will be a simple Todo List App, with REST API, that allows you to create, read, update, and delete tasks. We will also add some tests to demonstrate how to test the business logic of your application.
While this may seem like a simple example, the same exact principles can be applied to build way more complex applications. In fact, we’re using the same exact principles in our production applications.
First, let’s create a new App:
go mod init github.com/depshubhq/go-clean-architecture
Definining Domain Entities
Let’s start by defining the domain entities for our application. In this case,
we’re going to define a Todo
entity that represents a single task in our Todo
App. Create a new file called domain.go
and add the following code:
package main
type Status int
var (
TodoState Status = 1
DoingState Status = 2
DoneState Status = 3
)
type Todo struct {
ID uint
Title string
Status Status
}
The code above describes a simple Todo
entity that has an ID
, Title
, and
Status
field. The Status
field is an enum that represents the current state
of the task.
We’re not going to define a separate DTO (Data Transfer Object) for our entity to keep things simple. In a more complex application, you might want to or even should define a separate DTO to separate the domain entity from the data that is sent between different layers of your application. This can be done manually or using code generation tools such as goverter.
Defining Use Cases (Services)
This is the main layer of our application, where all the business logic is going
to be. We’re going to define a TodoService
that will contain all the
necessary methods to interact with the Todo
entity. Create a new file called
todo.go
and add the following code:
package main
type TodoStorage interface {
Create(todo Todo) error
Update(todo Todo) error
Delete(id uint) error
List() ([]Todo, error)
}
type TodoService struct {
storage TodoStorage
}
func NewTodoService(storage TodoStorage) *TodoService {
return &TodoService{storage: storage}
}
func (s *TodoService) Create(todo Todo) error {
return s.storage.Create(todo)
}
func (s *TodoService) Update(todo Todo) error {
return s.storage.Update(todo)
}
func (s *TodoService) Delete(id uint) error {
return s.storage.Delete(id)
}
func (s *TodoService) List() ([]Todo, error) {
return s.storage.List()
}
Wait, what is TodoStorage
and why are we defining it here?
The TodoService
has a dependency on the TodoStorage
interface. We don’t want
our business logic to depend on any specific implementation of the storage (it could
be a database, an in-memory storage, or an external API). Instead, we define a set of methods
that we want the storage to implement, and then we can pass any implementation we want.
This principle is called Dependency Inversion and it is one of the key principles of Clean Architecture. It allows us to easily swap out different implementations of the storage without changing the business logic.
You can also notice that for now, our TodoService
is just a thin layer that delegates
everything to the TodoStorage
(we’re going to implement more logic there in a bit).
In a more complex application, you will have more logic here. It’s highly recommended
to don’t skip the TodoService
layer even if it just delegates the calls to the storage.
This will make your application more flexible and maintainable in the long run.
Consistency is key!
Implementing the In-Memory Repository
Let’s create an in-memory implementation of the TodoStorage
interface. Create a new
file called inmemory.go
:
package main
import "fmt"
type TodoStorageInMemory struct {
todos []Todo
}
func NewTodoStorageInMemory() *TodoStorageInMemory {
return &TodoStorageInMemory{
todos: []Todo{},
}
}
func (s *TodoStorageInMemory) Create(todo Todo) error {
s.todos = append(s.todos, todo)
return nil
}
func (s *TodoStorageInMemory) Update(todo Todo) error {
for i, t := range s.todos {
if t.ID == todo.ID {
s.todos[i] = todo
return nil
}
}
return fmt.Errorf("todo not found")
}
func (s *TodoStorageInMemory) Delete(id uint) error {
for i, t := range s.todos {
if t.ID == id {
s.todos = append(s.todos[:i], s.todos[i+1:]...)
return nil
}
}
return fmt.Errorf("todo not found")
}
func (s *TodoStorageInMemory) List() ([]Todo, error) {
return s.todos, nil
}
You see that we have implemented the TodoStorage
interface with an in-memory storage.
Under the hood, we’re using a simple slice to store the Todo
entities.
Defining Controllers
This is the layer where we interact with the outside world. In this case, we’re going
to create a simple HTTP server that exposes a REST API to interact with the TodoService
.
Create a new file called restapi.go
and add the following code:
package main
import (
"encoding/json"
"net/http"
)
type TodoController struct {
service *TodoService
}
func NewTodoController(service *TodoService) *TodoController {
return &TodoController{service}
}
func (c *TodoController) Create(w http.ResponseWriter, r *http.Request) {
todo, err := c.parseTodoRequest(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
}
if err := c.service.Create(todo); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusCreated)
}
func (c *TodoController) Update(w http.ResponseWriter, r *http.Request) {
todo, err := c.parseTodoRequest(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
}
if err := c.service.Update(todo); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (c *TodoController) Delete(w http.ResponseWriter, r *http.Request) {
todo, err := c.parseTodoRequest(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
}
if err := c.service.Delete(todo.ID); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (c *TodoController) List(w http.ResponseWriter, r *http.Request) {
todos, err := c.service.List()
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(todos)
}
func (c *TodoController) parseTodoRequest(r *http.Request) (Todo, error) {
var todo Todo
if err := json.NewDecoder(r.Body).Decode(&todo); err != nil {
return Todo{}, err
}
return todo, nil
}
You can see that our TodoController
accepts a TodoService
as a dependency
and exposes a set of methods that handle HTTP requests. Now that we have methods to
interact with the TodoService
to create, update, delete, and list tasks, we can
combine everything together in our main.go
file:
Putting Everything Together
Now that we have all the layers of our application defined, we can put everything
together in our main.go
file:
// main.go
package main
import (
"log"
"net/http"
)
func main() {
storage := NewTodoStorageInMemory()
service := NewTodoService(storage)
controller := NewTodoController(service)
http.HandleFunc("/todo", func(w http.ResponseWriter, r *http.Request) {
log.Printf("Request: %s %s", r.Method, r.URL.Path)
switch r.Method {
case http.MethodPost:
controller.Create(w, r)
case http.MethodPut:
controller.Update(w, r)
case http.MethodDelete:
controller.Delete(w, r)
case http.MethodGet:
controller.List(w, r)
default:
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
http.ListenAndServe(":8080", nil)
}
Run the server using go run .
command. Let’s try to see if it works!
$ curl http://localhost:8080/todo
[]
$ curl -X POST http://localhost:8080/todo \
-d '{"ID": 1, "Title": "Learn Go", "Status": 1}'
$ curl http://localhost:8080/todo
# [{"ID":1,"Title":"Learn Go","Status":1}]
$ curl -X PUT http://localhost:8080/todo \
-d '{"ID": 1, "Title": "Learn Go - Updated", "Status": 2}'
$ curl http://localhost:8080/todo
# [{"ID":1,"Title":"Learn Go - Updated","Status":2}]
$ curl -X DELETE http://localhost:8080/todo -d '{"ID": 1}'
$ curl http://localhost:8080/todo
# []
$ curl -X DELETE http://localhost:8080/todo -d '{"ID": 1}'
# todo not found
Implementing the “real” PostgresQL Repository
Now that we have our application working with an in-memory storage,
let’s make a new implementation of the TodoStorage
interface that uses
PostgresQL as a storage.
To keep it simple, we’re going to use the lib/pq
package to interact with
the PostgresQL database.
I used a simple PostgresQL database with the following schema:
CREATE TABLE todos (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
status SMALLINT NOT NULL CHECK (status IN (1, 2, 3))
);
First, install the lib/pq
package:
go get github.com/lib/pq
Then, create a new file called postgres.go
and add the following code:
package main
import (
"database/sql"
"log"
_ "github.com/lib/pq"
)
type TodoStoragePostgres struct {
db *sql.DB
}
func NewTodoStoragePostgres() *TodoStoragePostgres {
// Change the connection string to match your PostgresQL database
db, err := sql.Open("postgres", "postgres://postgres@localhost/todos?sslmode=disable")
if err != nil {
log.Fatal(err)
return nil
}
return &TodoStoragePostgres{
db: db,
}
}
func (s *TodoStoragePostgres) Create(todo Todo) error {
_, err := s.db.Exec("INSERT INTO todos (id, title, status) VALUES ($1, $2, $3)", todo.ID, todo.Title, todo.Status)
return err
}
func (s *TodoStoragePostgres) Update(todo Todo) error {
_, err := s.db.Exec("UPDATE todos SET title = $1, status = $2 WHERE id = $3", todo.Title, todo.Status, todo.ID)
return err
}
func (s *TodoStoragePostgres) Delete(id uint) error {
_, err := s.db.Exec("DELETE FROM todos WHERE id = $1", id)
return err
}
func (s *TodoStoragePostgres) List() ([]Todo, error) {
rows, err := s.db.Query("SELECT id, title, status FROM todos")
if err != nil {
return nil, err
}
todos := []Todo{}
for rows.Next() {
todo := Todo{}
err := rows.Scan(&todo.ID, &todo.Title, &todo.Status)
if err != nil {
log.Println(err)
}
todos = append(todos, todo)
}
return todos, nil
}
You can see that we have implemented the same TodoStorage
interface
but this time using PostgresQL as a storage.
The key point here is that we can easily swap out the in-memory storage with our PostgresQL storage without changing the business logic of our application.
Change the main.go
file to use the PostgresQL storage:
-- storage := NewTodoStorageInMemory()
++ storage := NewTodoStoragePostgres()
Now, if you run the server again and try to create, update, delete, and list, you will see that the data is being stored in the PostgresQL database.
We changed the storage implementation without changing the business logic of our application. This is the power of Clean Architecture!
Testing
One of the main advantages of Clean Architecture is that it makes it easier to write tests for your application. Since the business logic is separated from the rest of the application, you can easily test it in isolation.
Create a new file called todo_test.go
and add the following code:
package main
import "testing"
// Test for Create method
func TestTodoService_Create(t *testing.T) {
mockStorage := &TodoStorageInMemory{}
service := NewTodoService(mockStorage)
todo := Todo{ID: 1, Title: "Test Todo", Status: TodoState}
err := service.Create(todo)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(mockStorage.todos) != 1 {
t.Fatalf("expected 1 todo, got %d", len(mockStorage.todos))
}
if mockStorage.todos[0] != todo {
t.Fatalf("expected todo %v, got %v", todo, mockStorage.todos[0])
}
}
// Test for Update method
func TestTodoService_Update(t *testing.T) {
mockStorage := &TodoStorageInMemory{todos: []Todo{{ID: 1, Title: "Old Todo", Status: TodoState}}}
service := NewTodoService(mockStorage)
updatedTodo := Todo{ID: 1, Title: "Updated Todo", Status: DoingState}
err := service.Update(updatedTodo)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if mockStorage.todos[0] != updatedTodo {
t.Fatalf("expected todo %v, got %v", updatedTodo, mockStorage.todos[0])
}
}
// Test for Delete method
func TestTodoService_Delete(t *testing.T) {
mockStorage := &TodoStorageInMemory{todos: []Todo{{ID: 1, Title: "Test Todo", Status: TodoState}}}
service := NewTodoService(mockStorage)
err := service.Delete(1)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(mockStorage.todos) != 0 {
t.Fatalf("expected 0 todos, got %d", len(mockStorage.todos))
}
}
// Test for List method
func TestTodoService_List(t *testing.T) {
mockStorage := &TodoStorageInMemory{todos: []Todo{
{ID: 1, Title: "Todo 1", Status: TodoState},
{ID: 2, Title: "Todo 2", Status: DoingState},
}}
service := NewTodoService(mockStorage)
todos, err := service.List()
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(todos) != 2 {
t.Fatalf("expected 2 todos, got %d", len(todos))
}
}
This uses our previous in-memory storage implementation to test the business layer of our application.
Run the tests using the go test
command:
$ go test
# ok github.com/depshubhq/golang-clean-architecture 0.333s
The “boring”, maintainable code
You probably noticed that we have a lot of repetitive code in our application and it requires a lot of boilerplate to create new entities, services, controllers, etc. The good news is that once you have the skeleton of your application set up, you move quickly and add new features in a predictable and maintainable way.
Let’s say, we want to implement a new requirement: each task shouldn’t be shorter
than 5 characters.
Whenever we have a new requirement we should think about where it fits in our
application. In this case, it is a business rule, so it should be implemented in
the TodoService
layer. In some cases, it might be a data manipulation (eg sorting) rule that
should be executed directly in the storage layer.
We can easily add this validation to our TodoService
:
++ const MaxTitleLength = 5
func (s *TodoService) Create(todo Todo) error {
++ if len(todo.Title) < MaxTitleLength {
++ return fmt.Errorf("title should be at least %d characters", MaxTitleLength)
++ }
return s.storage.Create(todo)
}
Let's add a test for this new requirement:
```diff
// todo_test.go
func TestTodoService_Create(t *testing.T) {
...
++ // Test for the title length validation
++ todo = Todo{ID: 2, Title: "Test", Status: TodoState}
++ err = service.Create(todo)
++ if err == nil {
++ t.Fatalf("expected error, got nil")
++ }
}
Final Thoughts
There are lot of other ways to make it even easier to work with this architecture. Some of them, such as using a dependency injection container, code generation or DTOs (Data Transfer Objects) are outside of the scope of this article and can be added later as your application grows.
This should give you a good starting point to implement Clean Architecture in your applications. Remember that the key principles are separation of concerns, dependency inversion and flexibility to change the implementation of each layer without affecting the others.
You can find the full code of the application in the repository.