[Personal Experience Using Clean Architecture in Golang]
Introduction
Does your service have clean architecture? Do you always know where to put a piece of code? Do you want to organize code meaningfully to ensure easy support, extension, and testing for your service?
In this article, I'll demonstrate how I use clean architecture in real projects and discuss its advantages and disadvantages. Let's go!
What is clean architecture?
Clean architecture is a collection of principles that makes it possible to develop well-supported, tested, and extensible applications.
Four layers of an application are usually mentioned in clean architecture: Entities, Use Cases, Adapters, and Infrastructure. Let's explore them through a specific example of an application for booking train tickets.
Entities layer
Entities are objects and business rules for a subject area. An entity could be a structure with methods or a collection of functions and a data structure (like a DTO).
Entities are applied in use cases and in adapters.
Entities aren't dependent on the other layers of an application.
The entities of my projects are located in the directory internal/model if I don’t import them from other services or in the directory pkg/model if I import them.
A railroad station entity (pkg/model/station.go):
package model
import "time"
type Station struct {
ID int `json:"id"`
CreatedAt time.Time `json:"createdAt"`
Code string `json:"code"`
Name string `json:"name"`
}
Ticket buyer entity (pkg/model/customer.go):
package model
import "time"
type Customer struct {
ID int `json:"id"`
CreatedAt time.Time `json:"createdAt"`
Email string `json:"email"`
}
The reservation entity and list for booking status (pkg/model/booking.go):
package model
import "time"
type BookingStatus string
const (
StatusTemporary BookingStatus = "TEMPORARY"
StatusConfirmed BookingStatus = "CONFIRMED"
StatusCancelled BookingStatus = "CANCELLED"
StatusFinished BookingStatus = "FINISHED"
)
func (bc BookingStatus) String() string {
return string(bc)
}
type Booking struct {
ID int `json:"id" db:"id"`
CustomerID int `json:"customerID" db:"customer_id"`
CreatedAt time.Time `json:"createdAt" db:"created_at"`
Number string `json:"number" db:"number"`
Status BookingStatus `json:"status" db:"status"`
From *Station `json:"from"`
To *Station `json:"to"`
}
Use cases layer
Use cases describe how your application functions. For example, the use cases for our application could be:
1. Station mask search
2. Train search
3. Reservation
4. Cancelling reservation
5. Reservation list
Use cases accept input data, process it according to the business logic using adapters and entities, and then return a result. The input and output data are given as entities and simple data types. This layer contains the application's business logic.
Use cases depend on the entities and adapters. Use cases don't depend on the implementation details; therefore, we don't need to create a dependency between use cases and the infrastructure layer. There's also no need to follow this rule like a fanatic. For example, you can create a dependency on the logger from the infrastructure layer if you use it in the whole project without plans to change it, and if it assists you in development.
In my projects, I keep use cases in the directory internal/usecase. Each one is stored in a separate package so I can easily see an individual use case's dependencies. All of them are described as interfaces and injected to the use case through a constructor.
Creating a booking use case (internal/usecase/booking/create/create.go):
package create
import (
"context"
"fmt"
"clean-arch-example/pkg/model"
)
type BookingRepository interface {
CreateBooking(ctx context.Context, booking *model.Booking) error
}
type EventAdapter interface {
TriggerEvent(ctx context.Context, event model.EventCode, data any) error
}
type UseCase struct {
bookingRepo BookingRepository
eventAdapter EventAdapter
}
func NewUseCase(bookingRepo BookingRepository, eventAdapter EventAdapter) *UseCase {
return &UseCase{bookingRepo: bookingRepo, eventAdapter: eventAdapter}
}
func (u *UseCase) CreateBooking(ctx context.Context, b *model.Booking) error {
b.Status = model.StatusTemporary
if err := u.bookingRepo.CreateBooking(ctx, b); err != nil {
return fmt.Errorf("repo create booking: %w", err)
}
if err := u.eventAdapter.TriggerEvent(ctx, model.BookingCreated, b); err != nil {
return fmt.Errorf("trigger event: %w", err)
}
return nil
}
This use case depends on two adapters. One saves the booking in the repository, while the other creates an event that the booking was completed. We can easily test this type of use case through unit tests with dependency mocks. When the details of our application's implementation change (such as the transport of user requests or databases), our use case barely changes or doesn't change at all, since it is independent of the infrastructure layer.
Adapters layer
Adapters are the interlayer between the business logic (use cases layer) and the implementation details (infrastructure layer). They transform specific queries (for example, HTTP queries, AMQP messages, and commands through the cli interface) into use case calls. The result that comes from use cases is then transformed by the adaptors into the right format of an answer. Adapters also convert calls from use cases to calls of specific functions in the infrastructure layer and an answer into entities and simple data types for use cases. The adapter layer holds API / AMQ (async message queue) handlers, and your console application's commands, the repository for data storage, and clients for external services.
Adapters depend on entities, other adapters, and the infrastructure layer. They aren't dependent on use cases, because use cases apply them.
In my projects, I put adapters in the directory internal/adapter. Each adapter is stored in a separate package that allows me to clearly see its dependencies. All of them are described as interfaces and included in the adapters through input parameters of the functions (if they are handlers).
Handler for creating a booking (internal/adapter/ui/http/api/v1/booking/create/create.go):
package create
import (
"context"
"encoding/json"
"fmt"
"net/http"
"clean-arch-example/internal/infrastructure/logger"
httpErr "clean-arch-example/internal/infrastructure/http/error"
"clean-arch-example/internal/infrastructure/http/writer"
"clean-arch-example/pkg/model"
)
type UseCase interface {
CreateBooking(ctx context.Context, b *model.Booking) error
}
type request struct {
Booking *model.Booking `json:"booking"`
}
type response struct {
Booking *model.Booking `json:"booking"`
}
func CreateBooking(uc UseCase) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
log := logger.GetLogger(ctx).WithField("handler", "CreateBooking")
ctx = logger.WithLogger(ctx, log)
req, err := parseRequest(r)
if err != nil {
httpErr.BadRequest(ctx, w, fmt.Errorf("parse request: %w", err))
return
}
if err := uc.CreateBooking(ctx, req.Booking); err != nil {
httpErr.InternalError(ctx, w, fmt.Errorf(
"usecase create booking: %w, booking: %+v",
err, req.Booking,
))
return
}
resp := response{
Booking: req.Booking,
}
writer.WriteJSON(ctx, w, resp)
}
}
func parseRequest(r *http.Request) (*request, error) {
var createReq request
if err := json.NewDecoder(r.Body).Decode(&createReq); err != nil {
return nil, fmt.Errorf("json decode: %w", err)
}
return &createReq, nil
}
This handler depends on entities, use cases, and on implementation details (logger, packets for working with http). It creates the handler for the HTTP server which will call the received use case. Since this package is used in the infrastructure layer (HTTP router), it returns the required data type for this layer.
Booking repository (internal/adapter/repository/booking.go):
package repository
import (
"context"
"fmt"
"time"
"github.com/Masterminds/squirrel"
"github.com/georgysavva/scany/pgxscan"
"clean-arch-example/internal/infrastructure/postgres"
"clean-arch-example/pkg/model"
)
type BookingRepository struct {
db *postgres.Postgres
}
func NewBookingRepository(db *postgres.Postgres) *BookingRepository {
return &BookingRepository{
db: db,
}
}
func (r *BookingRepository) CreateBooking(ctx context.Context, booking *model.Booking) error {
builder := r.db.Builder.
Insert("bookings").
Columns(
"created_at", "customer_id", "from_station_id", "to_station_id",
"number", "status",
).
Values(
time.Now().UTC(), booking.CustomerID, booking.From.ID, booking.To.ID,
booking.Number, booking.Status,
).
Suffix("RETURNING id, created_at")
sqlQuery, args, err := builder.ToSql()
if err != nil {
return fmt.Errorf("to sql: %w", err)
}
row := r.db.DB(ctx).QueryRow(ctx, sqlQuery, args...)
if err = row.Scan(&booking.ID, &booking.CreatedAt); err != nil {
return fmt.Errorf("row scan: %w", err)
}
return nil
}
This repository depends on entities and at implementation details (package for working with databases). It saves the received booking in the Postgres database, using its client. Since this package is used in a use case, it can only receive and return simple types and entities without implementation details.
Infrastructure layer
The infrastructure layer keeps the implementation details, including specific clients for storing your application's data (such as the Postgres, Redis, and AMQP clients), clients for external API (such as the API of another external service), the logger for your application, and the implementation of the http server, etc.
Infrastructure packages depend on external packages and other packages in the infrastructure layer. They don't depend on other layers of the application.
In my projects, I put infrastructure packages in the directory internal/infrastructure. Each infrastructure library is stored in a separate package that helps me clearly see its dependencies.
Client for working with database (internal/infrastructure/postgres/postgres.go):
package postgres
import (
"context"
"fmt"
"time"
"github.com/Masterminds/squirrel"
"github.com/jackc/pgconn"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
"clean-arch-example/internal/infrastructure/logger"
)
const (
_defaultMaxPoolSize = 10
_defaultConnAttempts = 3
_defaultConnTimeout = time.Second
)
type DB interface {
Exec(ctx context.Context, sql string, arguments ...interface{}) (pgconn.CommandTag, error)
Query(ctx context.Context, sql string, args ...interface{}) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...interface{}) pgx.Row
SendBatch(ctx context.Context, b *pgx.Batch) pgx.BatchResults
}
type Postgres struct {
maxPoolSize int
connAttempts int
connTimeout time.Duration
Pool *pgxpool.Pool
Builder squirrel.StatementBuilderType
}
func New(ctx context.Context, url string, opts ...Option) (*Postgres, error) {
pg := &Postgres{
maxPoolSize: _defaultMaxPoolSize,
connAttempts: _defaultConnAttempts,
connTimeout: _defaultConnTimeout,
}
pg.Builder = squirrel.StatementBuilder.PlaceholderFormat(squirrel.Dollar)
// Custom options
for _, opt := range opts {
opt(pg)
}
poolConfig, err := pgxpool.ParseConfig(url)
if err != nil {
return nil, fmt.Errorf("parse config: %w", err)
}
poolConfig.MaxConns = int32(pg.maxPoolSize)
log := logger.GetLogger(ctx)
for pg.connAttempts > 0 {
pg.Pool, err = pgxpool.ConnectConfig(ctx, poolConfig)
if err == nil {
return pg, nil
}
pg.connAttempts--
log.Info("postgres is trying to connect, attempts left: ", pg.connAttempts)
time.Sleep(pg.connTimeout)
}
return nil, fmt.Errorf("postgres connect: %w", err)
}
func (p *Postgres) Close() {
if p.Pool != nil {
p.Pool.Close()
}
}
func (p *Postgres) DB(ctx context.Context) DB {
return p.Pool
}
Conclusion
Clean architecture is a good choice for development enterprises and startups. It doesn’t need to be used for validating concepts or when creating a prototype that will later be discarded since additional time is spent on the proper organization of code when a service starts. To save time creating a new service, use a template implemented in clean architecture.
Advantages:
- Low code coupling. You can test and change each layer separately.
- High cohesion. Each package has a clear goal and contains the only required dependencies.
- Clear boundaries for layers. You always know where to put a package.
- Good for team work. Tasks can be easily distributed between several developers. For example, one can add the required methods to the repository while another can implement a use case and handler.
- Good modularity. When expanding a code base, the clean architecture is saved. If necessary, you can move part of the use cases to a separate microservice to work with a specific scope.
Disadvantages:
- Additional difficulty. For effective use, you need to know the principles of this architecture and have experience with it.
- When using several structures for business entities and storage models, you need to conduct extensive mapping between them. This is done with the same structures for business entities and storage as in the example here.
- Sometimes, there are difficulties when working with database transactions to ensure that implementation details don't come to use cases. This is resolved by storing transactions in a context and using special functions for launching functions in a single transaction.
To decide if you should use clean architecture or not, try it on a project and make your own conclusions.