The Clean Arch created by Uncle Bob is amazingly useful and robust, but it's very complex for beginners and very costly to maintain, mainly for small startups.
In this article, I'll explain "my version" of Clean Arch, that tries it's best to simplify the original architecture while maintaining good part of it's robustness.
One thing to emphasize is: This is an architecture pattern so it's language agnostic and framework agnostic. You can use it in any language, framework, library, project, that you want to.
If you want to know more about Clean Arch, you can read the book or read this article.
Dictionary
Before we start with the architecture, it's very important to define the meaning of some words.
Contracts and Implementations
Contracts are specifications for how to implement something. They don't do anything, they only tell you how to do something, but unlike your project manager, it tell how to do it in the right way. They can be view as abstract classes, interfaces or types.
On the other hand, Implementations are the things that really do something, following the instructions of the Contracts. They can be view as classes or functions.
Domains
Domain are a very subjective concept, it's up to you to analyse your business, its requirements and define what should be a domain.
Some examples of domains for a social network:
- Auth
- User
- Post
- Comment
- Like
The core concepts
Controllers
This is the layer to expose your application to the users, it's the only part that the user has access to. It can be a HTTP server, a GraphQL server, a Queue handler, a Cron Job, anything that can call your application.
This layer is responsible for:
- Receiving the requests (pooling for messages on queues, etc)
- Validating the inputs using the Validators layer and handling the validation error if it happens
- Calling the UseCases with the validated input
- Returning the response (UseCase output) in the correct format (including errors)
Validators
This is the layer to validate the inputs: Ensure that all the necessary data is being received, is in the correct format with the correct types, very simple, very slim.
Models
Models are the Contracts for the Repositories and Usecases.
UseCases
This is where all your business rules are at: All the "Can user do this", "If this happens, do this". The UseCases are the "most complex"/biggest part of our system, because it is responsible for using all the other components (Repositories and Adapters) to build a flow to execute something.
Repositories
This layer is responsible for communicating with the database, wrapping your queries (with veeery little of business rules, only the simplest ones, examples below) and abstracting them on simple methods that can be used for your UseCases.
Adapters
Adapters are responsible for abstracting / wrapping external libraries, APIs and dependencies in general. They can be used both from Repositories and UseCases.
How do we build something using this architecture?
- Create a POC: A minimalist version of what we are trying to build, without worry for code readability, mutability or struct, just put everything in one function in one file, used to learn the requirements for the final product.
- Using the knowledge got from the last step, we create all the Contracts (Models) that we need (including the one for the adapters)
- Implement the Adapters following their Contracts
- Implement the Repositories following their Contracts
- Implement the UseCases following their Contracts
- Implement the Validators that you will need
- Implement the Delivery layer (HTTP server with it's routes, queue system, cron, etc)
- Put everything together and it's done!
Example
I'll provide you guys an example in Golang, but this can be applied to any language and framework.
BUT REMEMBER: This is an extremely simple example that SHOULD NOT BE USED IN PRODUCTION!!!
Folders Structure
Let's start from the folders structure:
As you can see, it's very basic and straight forward: it has one folder for each core concept of the architecture.
Models Example
Not all models must have UseCases, the same way that not all models must have Repositories or Entities (representations of the database tables, used to type things usually returned by the Repositories).
In our example we have 2 models: User and Auth.
// internal/models/user.go
package models
// ----------------------------
//
// Repository
//
// ----------------------------
type CreateUserInput struct {
Email string
}
type CreateUserOutput struct {
Id string
}
type UserRepository interface {
Create(i *CreateUserInput) (*CreateUserOutput, error)
}
On our user model, we only have the contract for the Repository.
// internal/models/auth.go
package models
// ----------------------------
//
// UseCase
//
// ----------------------------
type CreateFromEmailInput struct {
Email string `json:"email" validate:"required,email"`
}
type AuthOutput struct {
UserId string `json:"userId"`
}
type AuthUsecase interface {
CreateFromEmailProvider(i *CreateFromEmailInput) error
}
And on our auth model, we only have the contract for the UseCase.
We could have done it in only 1 model, but I choose to split it in to models to explain to you guys the multiple ways that models can be used.
Adapter Example
You can see that we have 2 main components here:
- An
implementations
folder - And an
id.go
file
The id.go
is the Contract for the adapter. Unlike Repositories and UseCases, the contracts for the Adapters are grouped in the adapters folder and not in the Models.
And inside the implementations
folder we have the real implementations for the contracts.
// internal/adapters/id.go
package adapters
type IdAdapter interface {
GenId() (string, error)
}
On the id.go
, you can see that we define a Contract for a adapter that generates an ID. you can see that the contract doesn't care if the ID is an UUID, ULID, number, or how it's generated, it only cares that the ID must be an string.
// internal/adapters/implementations/ulid/ulid.go
package ulid
import (
"github.com/oklog/ulid/v2"
)
type Ulid struct {
}
func (adp *Ulid) GenId() (string, error) {
return ulid.Make().String(), nil
}
On the implementations/ulid/ulid.go
is where we implement the contract, generating the ID, here we use the oklog/ulid
library to generate an ULID.
You can see that the folder and file names are relative to how they are implementing it (using ULID) and not the Contract. This is because we can have multiple types of implementations for the same contract, like having both implementations/ulid/ulid.go
and implementations/uuid/uuid.go
implementing the IdAdapter
.
Repository Example
here we have only the implementations directly, because the Contracts are defined in the Models.
// internal/repositories/user.go
package repositories
import (
"database/sql"
"errors"
"example/internal/adapters"
"example/internal/models"
)
type UserRepository struct {
Db *sql.DB
IdAdapter adapters.IdAdapter
}
func (rep *UserRepository) Create(i *models.CreateUserInput) (*models.CreateUserOutput, error) {
accountId, err := rep.IdAdapter.GenId()
if err != nil {
return nil, errors.New("fail to generate id")
}
_, err = rep.Db.Exec(
"INSERT INTO users (id, email) VALUES ($1)",
accountId,
i.Email,
)
if err != nil {
return nil, errors.New("fail to create account")
}
return &models.CreateUserOutput{
Id: accountId,
}, nil
}
Usecase Example
// internal/usecases/user.go
package usecases
import (
"errors"
"example/internal/models"
)
type AuthUsecase struct {
UserRepository models.UserRepository
}
func (serv *AuthUsecase) CreateFromEmailProvider(i *models.CreateFromEmailInput) error {
_, err := serv.UserRepository.Create(&models.CreateUserInput{
Email: i.Email,
})
if err != nil {
return errors.New("fail to create user")
}
return nil
}
We can see that the Usecase receives the UserRepository
as a dependency injection, and then uses it to create an user.
This is a very simplistic version of a usecase, but in here you cold do thing like:
- Check if there are any other user with the same email (using a Repository) and return an error
- Send a welcome email (using an Adapter)
Validators Examples
In our case, we already implemented the validators!
// internal/models/auth.go
// ...
type CreateFromEmailInput struct {
Email string `json:"email" validate:"required,email"`
}
// ...
In the Golang implementation, the input for the usecase already is the validator. Here we are using go-playground/validator to do this validations, and the implementation for this is simply usign the tag validate
.
In other implementations, you may want to create a folder delivery/dtos
and put your validations there, grouped by domain, or any other scenario that fits best your case.
Delivery Example (HTTP)
In the delivery layer, we have a folder for each delivery type (http, cron, queues, etc) and a validator.go
file to configure the validator. I'll not show you the content of validator.go
here because it doesn't matter for the architecture concept, but you can give a look at the repository on the end of this article do see a more detailed implementation.
// internal/delivery/http/index.go
package http
import (
"encoding/json"
"net/http"
"os"
"example/internal/delivery"
"example/internal/models"
"example/internal/utils"
)
func NewHttpDelivery(authUsecase models.AuthUsecase) {
router := http.NewServeMux()
validator := delivery.NewValidator()
server := &http.Server{
Addr: ":" + os.Getenv("PORT"),
Handler: router,
}
router.HandleFunc("POST /auth/email", func(w http.ResponseWriter, r *http.Request) {
body := &models.CreateFromEmailInput{}
err := json.NewDecoder(r.Body).Decode(body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = validator.Validate(body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
err = authUsecase.CreateFromEmailProvider(body)
if err != nil {
http.Error(w, err.Error(), err.(*utils.HttpError).HttpStatusCode())
return
}
})
server.ListenAndServe()
}
On the delivery implementation, it receives the AuthUseCase and uses it, validating the input before sending to the usecase.
Putting everything together
// main.go
package main
import (
"database/sql"
"os"
"example/adapters/implementations/ulid"
"example/delivery/http"
"example/repositories"
"example/usecases"
_ "github.com/lib/pq"
)
func main() {
db, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
panic(1)
}
defer db.Close()
// ----------------------------
//
// Adapters
//
// ----------------------------
ulidAdapter := &ulid.Ulid{}
// ----------------------------
//
// Repositories
//
// ----------------------------
userRepository := &repositories.UserRepository{
Db: db,
IdAdapter: ulidAdapter,
}
// ----------------------------
//
// Services
//
// ----------------------------
authUsecase := &usecases.AuthUsecase{
UserRepository: userRepository,
}
// ----------------------------
//
// Delivery
//
// ----------------------------
http.NewHttpDelivery(authUsecase)
}
On the main file of your system, you create an instance of every adapter, repository and usecase, and then use the usecases on your delivery layer.
Conclusion
Thanks for reading everything and feel free to share your thoughts on the comments to try to improve this architecture.
If you want a more detailed example (but that is kinda incomplete in some parts) you can check this repository.
Top comments (0)