Implementando Clean Architecture com Golang
Vinícius Boscardin
Posted on March 23, 2022
Clean architecture é um assunto muito abordado nos últimos tempos. Mas... Como podemos estruturar uma arquitetura limpa com golang?
Primeiramente precisamos entender que clean architecture é uma especificação e não uma implementação. As implementações da arquitetura mais famosas são:
- Hexagonal
- DCI
- Screaming
- Onion
Nosso exemplo vai usar a arquitetura hexagonal, ou também chamada Ports and Adapters. Com a arquitetura em mãos precisamos agora definir o cenário da nossa aplicação e os requisitos que precisam existir para contemplar todas as funcionalidades. Vamos deixar pré fixado que a solução a ser criada será consumida pelo protocolo http com REST.
Os requisitos são:
- Criação de produtos (id, nome, preço e descrição)
- Listagem de produtos (com paginação no servidor)
Com os requisitos definidos, bora codar isso ai!
Calma, ainda não! Vamos definir quais tecnologias vamos usar, banco de dados, drivers de conexão e mais algumas bibliotecas que vão nos ajudar a criar a aplicação.
Usaremos então:
- Banco de dados:
- Libs no go
- Pgx: Conexão com o banco de dados
- Mux: Roteador de solicitação e um dispatcher para combinar as solicitações recebidas com seus respectivos manipuladores.
- Go-paginate: Criação de queries para o postgres
- Viper: Configurações para o ambiente de dev/prod
- Testify: Teste
- Pgx Mock: Mock para o pgx connection pool
- Migrate: Rodar as atualizações do nosso banco de dados
Crie uma pasta no local desejado com o nome clean-go/
Na pasta, no seu editor preferido, estruture o projeto:
- adapter/
- http/
- main.go
- postgres/
- connector.go
- http/
- core/
- domain/
- product.go
- dto/
- product.go
- domain/
- database
- migrations
Database
Instale a CLI migrate para gerar os arquivos de migrations necessários para o projeto.
migrate create -ext sql -dir database/migrations -seq create_product_table
Edite o arquivo gerado em database/migrations/000001.create_product_table.up.sql
com o SQL da criação da tabela product.
CREATE TABLE product (
id SERIAL PRIMARY KEY NOT NULL,
name VARCHAR(50) NOT NULL,
price FLOAT NOT NULL,
description VARCHAR(500) NOT NULL
);
E também altere o arquivo database/migrations/000001.create_product_table.down.sql
.
DROP TABLE IF EXISTS product;
Go modules
Vamos inicializar os módulos do go com o comando:
# go mod init github.com/<seu usuario>/<nome do repo>
# no meu caso:
go mod init github.com/booscaaa/clean-go
DTO (Data Transfer Object)
Vamos começar editando o arquivo core/dto/product.go
e definindo o modelo de dados para a request de criação de um novo produto no servidor.
package dto
import (
"encoding/json"
"io"
)
// CreateProductRequest is an representation request body to create a new Product
type CreateProductRequest struct {
Name string `json:"name"`
Price float32 `json:"price"`
Description string `json:"description"`
}
// FromJSONCreateProductRequest converts json body request to a CreateProductRequest struct
func FromJSONCreateProductRequest(body io.Reader) (*CreateProductRequest, error) {
createProductRequest := CreateProductRequest{}
if err := json.NewDecoder(body).Decode(&createProductRequest); err != nil {
return nil, err
}
return &createProductRequest, nil
}
Em seguida definimos o DTO para nossas requests de paginação no arquivo core/dto/pagination.go
.
package dto
import (
"net/http"
"strconv"
"strings"
)
// PaginationRequestParms is an representation query string params to filter and paginate products
type PaginationRequestParms struct {
Search string `json:"search"`
Descending []string `json:"descending"`
Page int `json:"page"`
ItemsPerPage int `json:"itemsPerPage"`
Sort []string `json:"sort"`
}
// FromValuePaginationRequestParams converts query string params to a PaginationRequestParms struct
func FromValuePaginationRequestParams(request *http.Request) (*PaginationRequestParms, error) {
page, _ := strconv.Atoi(request.FormValue("page"))
itemsPerPage, _ := strconv.Atoi(request.FormValue("itemsPerPage"))
paginationRequestParms := PaginationRequestParms{
Search: request.FormValue("search"),
Descending: strings.Split(request.FormValue("descending"), ","),
Sort: strings.Split(request.FormValue("sort"), ","),
Page: page,
ItemsPerPage: itemsPerPage,
}
return &paginationRequestParms, nil
}
Domain
Com nosso DTO configurado podemos criar o core da nossa aplicação. Criaremos um aquivo chamado core/domain/pagination.go
.
package domain
// Pagination is representation of Fetch methods returns
type Pagination[T any] struct {
Items T `json:"items"`
Total int32 `json:"total"`
}
No arquivo core/domain/product.go
vamos definir o modelo de dados referente a tabela product do banco e também as interfaces para implementação dos métodos, precisamos definir basicamente 3 interfaces: service, usecase
e o nosso repository
.
O service irá atender as requisições externas que batem na nossa api, o usecase é a nossa regra de negócio e o repository é nosso adapter do banco de dados.
package domain
import (
"net/http"
"github.com/boooscaaa/clean-go/core/dto"
)
// Product is entity of table product database column
type Product struct {
ID int32 `json:"id"`
Name string `json:"name"`
Price float32 `json:"price"`
Description string `json:"description"`
}
// ProductService is a contract of http adapter layer
type ProductService interface {
Create(response http.ResponseWriter, request *http.Request)
Fetch(response http.ResponseWriter, request *http.Request)
}
// ProductUseCase is a contract of business rule layer
type ProductUseCase interface {
Create(productRequest *dto.CreateProductRequest) (*Product, error)
Fetch(paginationRequest *dto.PaginationRequestParms) (*Pagination[[]Product], error)
}
// ProductRepository is a contract of database connection adapter layer
type ProductRepository interface {
Create(productRequest *dto.CreateProductRequest) (*Product, error)
Fetch(paginationRequest *dto.PaginationRequestParms) (*Pagination[[]Product], error)
}
Repository
Com nosso domínio bem definido vamos começar definitivamente a implementação da nossa api. No arquivo adapter/postgres/connector.go
vamos configurar a conexão com o banco de dados.
package postgres
import (
"context"
"fmt"
"log"
"os"
"github.com/golang-migrate/migrate/v4"
"github.com/jackc/pgconn"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
"github.com/spf13/viper"
_ "github.com/golang-migrate/migrate/v4/database/pgx" //driver pgx used to run migrations
_ "github.com/golang-migrate/migrate/v4/source/file"
)
// PoolInterface is an wraping to PgxPool to create test mocks
type PoolInterface interface {
Close()
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
QueryFunc(
ctx context.Context,
sql string,
args []interface{},
scans []interface{},
f func(pgx.QueryFuncRow) error,
) (pgconn.CommandTag, error)
SendBatch(ctx context.Context, b *pgx.Batch) pgx.BatchResults
Begin(ctx context.Context) (pgx.Tx, error)
BeginFunc(ctx context.Context, f func(pgx.Tx) error) error
BeginTxFunc(ctx context.Context, txOptions pgx.TxOptions, f func(pgx.Tx) error) error
}
// GetConnection return connection pool from postgres drive PGX
func GetConnection(context context.Context) *pgxpool.Pool {
databaseURL := viper.GetString("database.url")
conn, err := pgxpool.Connect(context, "postgres"+databaseURL)
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err)
os.Exit(1)
}
return conn
}
// RunMigrations run scripts on path database/migrations
func RunMigrations() {
databaseURL := viper.GetString("database.url")
m, err := migrate.New("file://database/migrations", "pgx"+databaseURL)
if err != nil {
log.Println(err)
}
if err := m.Up(); err != nil {
log.Println(err)
}
}
Com nosso connector pronto, vamos implementar a interface ProductRepository lá do nosso domain, lembra? Criaremos a estrutura da implementação assim:
- adapter
- postgres
- productrepository
- new.go
- create.go
- fetch.go
- productrepository
- postgres
No arquivo adapter/postgres/productrepository/new.go
criaremos nossa vinculação com o "contrato" da interface ProductRepository.
package productrepository
import (
"github.com/boooscaaa/clean-go/adapter/postgres"
"github.com/boooscaaa/clean-go/core/domain"
)
type repository struct {
db postgres.PoolInterface
}
// New returns contract implementation of ProductRepository
func New(db postgres.PoolInterface) domain.ProductRepository {
return &repository{
db: db,
}
}
No arquivo adapter/postgres/productrepository/create.go
criaremos a lógica que contempla o metodo Create do nosso contrato.
package productrepository
import (
"context"
"github.com/boooscaaa/clean-go/core/domain"
"github.com/boooscaaa/clean-go/core/dto"
)
func (repository repository) Create(productRequest *dto.CreateProductRequest) (*domain.Product, error) {
ctx := context.Background()
product := domain.Product{}
err := repository.db.QueryRow(
ctx,
"INSERT INTO product (name, price, description) VALUES ($1, $2, $3) returning *",
productRequest.Name,
productRequest.Price,
productRequest.Description,
).Scan(
&product.ID,
&product.Name,
&product.Price,
&product.Description,
)
if err != nil {
return nil, err
}
return &product, nil
}
No arquivo adapter/postgres/productrepository/fetch.go
criaremos a lógica que contempla o método Fetch do nosso contrato.
package productrepository
import (
"context"
"github.com/boooscaaa/clean-go/core/domain"
"github.com/boooscaaa/clean-go/core/dto"
"github.com/booscaaa/go-paginate/paginate"
)
func (repository repository) Fetch(pagination *dto.PaginationRequestParms) (*domain.Pagination[[]domain.Product], error) {
ctx := context.Background()
products := []domain.Product{}
total := int32(0)
query, queryCount, err := paginate.Paginate("SELECT * FROM product").
Page(pagination.Page).
Desc(pagination.Descending).
Sort(pagination.Sort).
RowsPerPage(pagination.ItemsPerPage).
SearchBy(pagination.Search, "name", "description").
Query()
if err != nil {
return nil, err
}
{
rows, err := repository.db.Query(
ctx,
*query,
)
if err != nil {
return nil, err
}
for rows.Next() {
product := domain.Product{}
rows.Scan(
&product.ID,
&product.Name,
&product.Price,
&product.Description,
)
products = append(products, product)
}
}
{
err := repository.db.QueryRow(ctx, *queryCount).Scan(&total)
if err != nil {
return nil, err
}
}
return &domain.Pagination[[]domain.Product]{
Items: products,
Total: total,
}, nil
}
Repository pronto! :D
UseCase
Com nosso repository finalizado vamos implementar a regra de negócios da nossa aplicação. Criaremos a estrutura da implementação assim:
- core
- domain
- usecase
- productusecase
- new.go
- create.go
- fetch.go
- productusecase
- usecase
- domain
No arquivo core/domain/usecase/productusecase/new.go
criaremos nossa vinculação com o "contrato" da interface ProductUseCase.
package productusecase
import "github.com/boooscaaa/clean-go/core/domain"
type usecase struct {
repository domain.ProductRepository
}
// New returns contract implementation of ProductUseCase
func New(repository domain.ProductRepository) domain.ProductUseCase {
return &usecase{
repository: repository,
}
}
No arquivo core/domain/usecase/productusecase/create.go
criaremos a lógica que contempla o método Create do nosso contrato.
package productusecase
import (
"github.com/boooscaaa/clean-go/core/domain"
"github.com/boooscaaa/clean-go/core/dto"
)
func (usecase usecase) Create(productRequest *dto.CreateProductRequest) (*domain.Product, error) {
product, err := usecase.repository.Create(productRequest)
if err != nil {
return nil, err
}
return product, nil
}
No arquivo core/domain/usecase/productusecase/fetch.go
criaremos a lógica que contempla o método Fetch do nosso contrato.
package productusecase
import (
"github.com/boooscaaa/clean-go/core/domain"
"github.com/boooscaaa/clean-go/core/dto"
)
func (usecase usecase) Fetch(paginationRequest *dto.PaginationRequestParms) (*domain.Pagination[[]domain.Product], error) {
products, err := usecase.repository.Fetch(paginationRequest)
if err != nil {
return nil, err
}
return products, nil
}
Service
Com nosso usecase finalizado vamos implementar o adapter do http para receber as requisições da aplicação. Criaremos a estrutura da implementação assim:
- adapter
- http
- productservice
- new.go
- create.go
- fetch.go
- productservice
- http
No arquivo adapter/http/productservice/new.go
criaremos nossa vinculação com o "contrato" da interface ProductService.
package productservice
import "github.com/boooscaaa/clean-go/core/domain"
type service struct {
usecase domain.ProductUseCase
}
// New returns contract implementation of ProductService
func New(usecase domain.ProductUseCase) domain.ProductService {
return &service{
usecase: usecase,
}
}
No arquivo adapter/http/productservice/create.go
criaremos a lógica que contempla o método Create do nosso contrato.
package productservice
import (
"encoding/json"
"net/http"
"github.com/boooscaaa/clean-go/core/dto"
)
func (service service) Create(response http.ResponseWriter, request *http.Request) {
productRequest, err := dto.FromJSONCreateProductRequest(request.Body)
if err != nil {
response.WriteHeader(500)
response.Write([]byte(err.Error()))
return
}
product, err := service.usecase.Create(productRequest)
if err != nil {
response.WriteHeader(500)
response.Write([]byte(err.Error()))
return
}
json.NewEncoder(response).Encode(product)
}
No arquivo adapter/http/productservice/fetch.go
criaremos a lógica que contempla o método Fetch do nosso contrato.
package productservice
import (
"encoding/json"
"net/http"
"github.com/boooscaaa/clean-go/core/dto"
)
func (service service) Fetch(response http.ResponseWriter, request *http.Request) {
paginationRequest, err := dto.FromValuePaginationRequestParams(request)
if err != nil {
response.WriteHeader(500)
response.Write([]byte(err.Error()))
return
}
products, err := service.usecase.Fetch(paginationRequest)
if err != nil {
response.WriteHeader(500)
response.Write([]byte(err.Error()))
return
}
json.NewEncoder(response).Encode(products)
}
Tudo pronto! Brincadeira... Estamos quase lá, vamos configurar nossa injeção de dependências, nosso arquivo adapter/http/main.go
para rodar a aplicação e o arquivo json de configurações de conexão do banco de dados.
Para configurar a injeção de dependência do nosso product vamos criar um arquivo em di/product.go
.
package di
import (
"github.com/boooscaaa/clean-go/adapter/http/productservice"
"github.com/boooscaaa/clean-go/adapter/postgres"
"github.com/boooscaaa/clean-go/adapter/postgres/productrepository"
"github.com/boooscaaa/clean-go/core/domain"
"github.com/boooscaaa/clean-go/core/usecase/productusecase"
)
// ConfigProductDI return a ProductService abstraction with dependency injection configuration
func ConfigProductDI(conn postgres.PoolInterface) domain.ProductService {
productRepository := productrepository.New(conn)
productUseCase := productusecase.New(productRepository)
productService := productservice.New(productUseCase)
return productService
}
E por fim configurar nosso arquivo adapter/http/main.go
package main
import (
"context"
"fmt"
"log"
"net/http"
"github.com/boooscaaa/clean-go/adapter/postgres"
"github.com/boooscaaa/clean-go/di"
"github.com/gorilla/mux"
"github.com/spf13/viper"
)
func init() {
viper.SetConfigFile(`config.json`)
err := viper.ReadInConfig()
if err != nil {
panic(err)
}
}
func main() {
ctx := context.Background()
conn := postgres.GetConnection(ctx)
defer conn.Close()
postgres.RunMigrations()
productService := di.ConfigProductDI(conn)
router := mux.NewRouter()
router.Handle("/product", http.HandlerFunc(productService.Create)).Methods("POST")
router.Handle("/product", http.HandlerFunc(productService.Fetch)).Queries(
"page", "{page}",
"itemsPerPage", "{itemsPerPage}",
"descending", "{descending}",
"sort", "{sort}",
"search", "{search}",
).Methods("GET")
port := viper.GetString("server.port")
log.Printf("LISTEN ON PORT: %v", port)
http.ListenAndServe(fmt.Sprintf(":%v", port), router)
}
Agora só a configuração de conexão com o banco de dados e a porta que a api vai rodar no aquivo config.json
na raiz do projeto:
{
"database": {
"url": "://postgres:postgres@localhost:5432/devtodb"
},
"server": {
"port": "3000"
}
}
E a estrutura final ficou:
Hora da verdade!
Será mesmo que o projeto vai rodar lisinho? É o que veremos.
Para executar a api basta se posicionar na raiz do projeto e rodar:
go run adapter/http/main.go
Com isso vai aparecer algo assim no terminal:
Testando, 1..2..3.. Teste som!
Para criar um produto basta mandar um JSON em uma request POST na URL: localhost:port/product
Para listar os produtos com paginação é so mandar um GET maroto na URL localhost:port/product
Sua vez
Vai na fé! Acredito totalmente em você, independente do seu nível de conhecimento técnico, você vai criar a melhor api em GO.
Se você se deparar com problemas que não consegue resolver, sinta-se à vontade para entrar em contato. Vamos resolver isso juntos.
Onde tá os testes unitários?
Bora lá, próximo post vamos abordar isso e também mexer bastante com o coverage do Go. Vai ser muito legal! Até logo
Posted on March 23, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.