Clean Architecture in Go

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 a Post.
  • 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 or GetPosts. Services can be depedant on the Repository and Entity 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:

Clean Architecture Diagram

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.

Clean Architecture Diagram with shared domain

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.

Clean Architecture Diagram for testing

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.

Features
Manage your dependencies on autopilot
We do much more than upgrading your dependencies.
Smart scheduling
Smart scheduling
There is no need to update every single library to the newest version. We keep your dependencies reasonably fresh to reduce noise and useless updates.
Continuous monitoring
Continuous monitoring
We monitor your dependencies and notify you about new releases and security vulnerabilities.
Noise reduction
Noise reduction
We filter out unnecessary updates and group them into a single pull request.
Intelligent updates
Intelligent updates
We manage major and minor updates for you. You can focus on your business logic.
Advanced security
Advanced security
We monitor your dependencies and notify you about new releases and security vulnerabilities.
Integrations
Integrations
We integrate with GitHub, GitLab, Bitbucket, Slack, and more.
DepsHub is available today
Join our Early Access program for free.
© DepsHub Inc. All rights reserved.