DEV Community

Henrique Leite
Henrique Leite

Posted on

A simplified version of Clean Arch

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

henriqueleite42 Clean Architecture

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)
    • We usually start from the contracts for the Adapters
    • And then create the Models
  • 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:

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

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

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)
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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

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
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"`
}

// ...
Enter fullscreen mode Exit fullscreen mode

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)

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()
}

Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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)