DEV Community

Jorge Gomez
Jorge Gomez

Posted on

Descubriendo la Potencia de Go: Backend Inicial con buenas practicas

Image description

En el vasto universo de la programación, cada lenguaje tiene su brillo único, su propósito especial. Entre estos, Go (o Golang) destaca por su simplicidad, eficiencia y capacidad para manejar concurrencia. Como desarrollador apasionado por aprender y explorar, decidí embarcarme en un viaje: crear un proyecto desde cero utilizando Go. Este artículo no solo subraya la importancia de Go en el desarrollo moderno de software, sino que también comparte mi experiencia construyendo una aplicación estructurada y funcional, paso a paso.

¿Por Qué Go?
Go fue diseñado en Google para mejorar la productividad en la programación en un entorno de sistemas complejos. Su sintaxis clara y su sistema de tipos, junto con el manejo nativo de la concurrencia mediante goroutines, lo hacen excepcionalmente potente para el desarrollo de servidores web, servicios en tiempo real y herramientas de productividad.

Mi fuerte esta con NodeJS usando NestJS, en los cuales he aprendido a lo largo de mi carrera, buenas practicas aplicando:

  • Arquitectura de 3 capas Repositorios, Servicios y Controladores
  • Manejo de Errores
  • Configuracion de base de datos
  • Migraciones
  • Organizacion inicial de configuraciones iniciales
  • Variables de Entorno

Quise llevar todas mis experiencias y moldearlo en un backend Go, con una estructura inicial de arquitectura, por lo tanto asi comence:

Go trabaja con un archivo principal llamado main.go, en el cual todas las instrucciones que iniciemos comenzaran en este lugar

package main

import "fmt"

func main() {
    fmt.Println("Hello world")
}
Enter fullscreen mode Exit fullscreen mode

Este es el clasico hola mundo para todos, pero lo dejo para que sepas como debe de iniciar , puede tener otro nombre que ustedes quieran , pero lo importante es que se dicho archivo debe de iniciarse con la instrucción:

go run main.go

Antes de ponernos manos a la obra, debemos tener claro nuestra estructura de carpetas para nuestro backend, esta estructura inicial puede permitirnos tener una buena organización en la distribución de responsabilidades en nuestro backend lo cual sera el siguiente

/api-golang
---/bootstrap - Configuración del contenedor de inyección de dependencias
---/config - Gestión de configuración centralizada
---/controller - Controladores HTTP
---/database - Conexión y configuración de la base
de datos
---/dto - Definicion de DTO para los
controladores
---/migrations - Migraciones de base de datos
---/errors - Definiciones de errores personalizados
---/models - Modelos de datos
---/repositories - Acceso y manipulación de la base de datos
---/services - Lógica de negocio
---main.go - Punto de entrada de la aplicación
---.env - Variables de entorno

Config

En esta carpeta tendremos un archivo llamado config.go, en el cual tendremos configurado las variables de entorno necesarias para usar en cualquier parte de nuestro backend usando la libreria github.com/joho/godotenv

package config

import (
    "log"
    "os"

    "github.com/joho/godotenv"
)

type Config struct {
    DBUser     string
    DBPass     string
    DBHost     string
    DBPort     string
    DBName     string
    DBSSLMode  string
    ServerPort string
}

func LoadConfig() Config {
    err := godotenv.Load()
    if err != nil {
        log.Println("Warning: No .env file found")
    }

    return Config{
        DBUser:     os.Getenv("DB_USER"),
        DBPass:     os.Getenv("DB_PASSWORD"),
        DBHost:     os.Getenv("DB_HOST"),
        DBPort:     os.Getenv("DB_PORT"),
        DBName:     os.Getenv("DB_NAME"),
        DBSSLMode:  os.Getenv("DB_SSLMODE"),
        ServerPort: os.Getenv("SERVER_PORT"),
    }
}
Enter fullscreen mode Exit fullscreen mode

el archivo .env debe de tener lo siguiente

DB_USER=postgres
DB_PASSWORD=123456
DB_HOST=localhost
DB_NAME=golang
DB_PORT=5432
DB_SSLMODE=disable
Enter fullscreen mode Exit fullscreen mode

Database

En esta carpeta tenemos el archivo db.go, el cual sera encargado de tener preparado nuestra conexión a la base de datos tomando en cuenta por la importacion "api-golang/config", aplicando el struct de config.go en func ConnectDatabase(cfg config.Config), podemos acceder a las variables de entorno necesarias para la configurar las credenciales de la base de datos

db.go

package database

import (
    "api-golang/config"
    "fmt"
    "log"

    "gorm.io/driver/postgres"
    "gorm.io/gorm"
)

func ConnectDatabase(cfg config.Config) (*gorm.DB, error) {
    dsn := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=%s",
        cfg.DBUser, cfg.DBPass, cfg.DBHost, cfg.DBPort, cfg.DBName, cfg.DBSSLMode)

    database, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
    if err != nil {
        log.Fatalf("Failed to connect to database: %v", err)
    }
    return database, nil
}
Enter fullscreen mode Exit fullscreen mode

Migrations

Es muy importante tener en nuestra estructura , un arranque inicial de una base de datos, por lo tanto por medio de la libreria github.com/golang-migrate/migrate/v4, tendremos las bondades de correr migraciones nuevas o modificaciones las cuales definiremos en archivos .sql, el archivo es

/migrations/migrations.go

package migrations

import (
    "log"
    "path/filepath"
    "runtime"

    "github.com/golang-migrate/migrate/v4"
    _ "github.com/golang-migrate/migrate/v4/database/postgres"
    _ "github.com/golang-migrate/migrate/v4/source/file"
)

func Migrate(dsn string) {
    _, b, _, _ := runtime.Caller(0)
    dir := filepath.Join(filepath.Dir(b), "migrations")

    m, err := migrate.New("file://"+dir, dsn)
    if err != nil {
        log.Fatalf("Error while creating migrate instance: %v", err)
    }

    // Apply all the available migrations
    if err := m.Up(); err != nil && err != migrate.ErrNoChange {
        log.Fatalf("Error while applying migrations: %v", err)
    }

    log.Println("Migrations applied successfully!")
}

Enter fullscreen mode Exit fullscreen mode

Para poder correr las migraciones por comandos cli, podemos usar un archivo Makefile, el cual es una forma de correr tareas personalizadas para GO, el cual se define asi

migrateup:
    migrate -path ./migrations -database "postgres://postgres:123456@localhost:5432/golang?sslmode=disable" up

migratedown:
    migrate -path ./migrations -database "postgres://postgres:123456@localhost:5432/golang?sslmode=disable" down
Enter fullscreen mode Exit fullscreen mode

y para correr el comando por consola make migrateup

al correr el comando puedes iniciar los archivos default que tengas de .sql como por ejemplo

/migrations/1_create_products_table.up.sql

-- +migrate Up
CREATE TABLE IF NOT EXISTS products (
    id SERIAL PRIMARY KEY,
    nombre VARCHAR(100) NOT NULL,
    precio NUMERIC NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

/migrations/1_create_products_table.down.sql

-- +migrate Down
DROP TABLE IF EXISTS products;
Enter fullscreen mode Exit fullscreen mode

Puedes crear todas las migraciones que necesites, la base de datos creara una tabla de control a fin de tener el seguimiento de las migraciones aplicadas.

Errors

Siempre es importante tener un archivo central , por el cual podemos contrar los errores por medio de un mensaje generico, el siguiente archivo tiene la configuracion inicial a usar para los errores

/errors/errors.go

package errors

// ErrorType enumerates the known error types for the application
type ErrorType int

const (
    DatabaseError ErrorType = iota + 1
    ValidationError
    InternalError
)

// AppError represents an error with an associated type and message
type AppError struct {
    Type    ErrorType
    Message string
}

func (e *AppError) Error() string {
    return e.Message
}

// New crea un nuevo AppError
func New(typ ErrorType, msg string) error {
    return &AppError{
        Type:    typ,
        Message: msg,
    }
}

Enter fullscreen mode Exit fullscreen mode

Repositorio

Aqui configuramos toda la logica relacionada a las consultas a la base de datos, puede ser de modo generico usando el ORM o queryBuilder, y ademas de consultas personalizadas, pero lo importante, es que la responsabilidad de manipular la base de datos solo estara en este sitio, en este caso tomando en cuenta una tabla de product, podemos tener el siguiente archivo

/repositories/product.go

package repositories

import (
    "api-golang/models"

    "gorm.io/gorm"
)

type ProductRepository interface {
    FindAll() ([]models.Product, error)
    Save(producto models.Product) (models.Product, error)
}

type productoRepository struct {
    db *gorm.DB
}

func NewProductoRepository(db *gorm.DB) ProductRepository {
    return &productoRepository{db: db}
}

func (r *productoRepository) FindAll() ([]models.Product, error) {
    var productos []models.Product
    result := r.db.Find(&productos)
    return productos, result.Error
}

func (r *productoRepository) Save(producto models.Product) (models.Product, error) {
    result := r.db.Create(&producto)
    return producto, result.Error
}
Enter fullscreen mode Exit fullscreen mode

Este repositorio en Go sirve como una capa de abstracción entre la lógica de negocio de nuestra aplicación y las operaciones de base de datos específicas para Product. Utiliza el ORM Gorm, una popular biblioteca de mapeo objeto-relacional en Go, que facilita la interacción con la base de datos utilizando Go structs en lugar de consultas SQL directas, en el siguiente bloque

// Struct a el ORM Gorm a fin de usar las bondades del orm
type productoRepository struct {
    db *gorm.DB
}
Enter fullscreen mode Exit fullscreen mode

productoRepository contiene un solo campo, db, que es un puntero a una instancia de gorm.DB. Este campo db representa una sesión de base de datos con Gorm, que se utiliza para realizar operaciones de base de datos (consultas, inserciones, actualizaciones, etc.) relacionadas con productos.

Al ser un puntero (*gorm.DB), db mantiene una referencia a la instancia de la base de datos, lo cual significa que los cambios que se realicen afectaran cualquier lado del backend, lo cual es crucial para mantener la actualizada la base de datos y al usarlo en ls siguiente funcion

func (r *productoRepository) FindAll() ([]models.Product, error) {
    var productos []models.Product
    result := r.db.Find(&productos)
    return productos, result.Error
}
Enter fullscreen mode Exit fullscreen mode

r *productoRepository tiene la asociacion a las bondades del orm usando r.db.Find, ademas de usarlo con tras funciones basicas del orm para registrar, eliminar etc.

Service

En esta capa tenemos la logica del negocio, el cual se llamara

/services/product.go

package services

import (
    "api-golang/errors"
    "api-golang/models"
    "api-golang/repositories"
)

type ProductService interface {
    GetAllProductos() ([]models.Product, error)
    CreateProduct(producto models.Product) (models.Product, error)
}

type productoService struct {
    repository repositories.ProductRepository
}

func NewProductoService(repository repositories.ProductRepository) ProductService {
    return &productoService{repository: repository}
}

func (s *productoService) GetAllProductos() ([]models.Product, error) {
    res, err := s.repository.FindAll()
    if err != nil {
        return []models.Product{}, errors.New(errors.DatabaseError, "Error find all product: "+err.Error())
    }
    return res, nil
}

func (s *productoService) CreateProduct(producto models.Product) (models.Product, error) {
    // Intenta guardar el producto y maneja tanto el producto guardado como el error
    savedProduct, err := s.repository.Save(producto)
    if err != nil {
        // Aquí manejas el error, creando un nuevo AppError con tipo DatabaseError
        return models.Product{}, errors.New(errors.DatabaseError, "Error saving product: "+err.Error())
    }
    // Si no hay error, devuelve el producto guardado y nil para el error
    return savedProduct, nil
}
Enter fullscreen mode Exit fullscreen mode

En este archivo aplicamos nuevamente una instancia en

type productoService struct {
   repository repositories.ProductRepository
}
Enter fullscreen mode Exit fullscreen mode

en el cual instanciamos la interfaz de ProductRepository a fin de acceder a las funciones definidas como FindAll en el siguiente codigo:

func (s *productoService) GetAllProductos() ([]models.Product, error) {
    res, err := s.repository.FindAll()
    if err != nil {
        return []models.Product{}, errors.New(errors.DatabaseError, "Error find all product: "+err.Error())
    }
    return res, nil
}
Enter fullscreen mode Exit fullscreen mode

En esta función accedemos a todos los productos , en el caso de que se tenga un error , se enviarn a la funcion DatabaseError del archivo definido mas arriba en /errors/errors.go

Controller

En esta capa tenemos el control de las solicitudes HTTP, en este lugar debemos validar lo que recibimos por payload o por params a fin de tener seguridad en el procesamiento de los datos, ademas se define la validacion de los datos usando DTO(Data Transfer Object) a fin de tener seguridad de los datos de entrada, para este caso se usaran solo dos endpoints los cuales haran el registro y obtencion de productos

/controller/product.go

package controller

import (
    "api-golang/dto"
    "api-golang/models"
    "api-golang/services"
    "encoding/json"
    "net/http"
)

type ProductController struct {
    Service services.ProductService
}

func NewProductController(service services.ProductService) *ProductController {
    return &ProductController{Service: service}
}

func (c *ProductController) GetProducts(w http.ResponseWriter, r *http.Request) {
    productos, err := c.Service.GetAllProductos()
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(productos)
}

func (c *ProductController) CreateProduct(w http.ResponseWriter, r *http.Request) {
    var productDTO dto.ProductCreateDTO
    if err := json.NewDecoder(r.Body).Decode(&productDTO); err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    // DTO Validation
    if validationErrors := productDTO.Validate(); validationErrors != nil {
        http.Error(w, "Validation failed", http.StatusBadRequest)
        return
    }

    product := models.Product{Nombre: productDTO.Nombre, Precio: productDTO.Precio}
    savedProduct, err := c.Service.CreateProduct(product)
    if err != nil {
        http.Error(w, err.Error(), http.StatusInternalServerError)
        return
    }
    json.NewEncoder(w).Encode(savedProduct)
}

Enter fullscreen mode Exit fullscreen mode

Por medio de

type ProductController struct {
Service services.ProductService
}

Accedemos a la instancia de la interfaz ProductService a fin de acceder a las funciones del servicio como por ejemplo c.Service.CreateProduct, para tener el response final json.NewEncoder(w).Encode(producto) realiza la serializacion a JSON por medio de json.NewEncoder(w)

En el bloque

// DTO Validation
if validationErrors := productDTO.Validate(); validationErrors != nil {
http.Error(w, "Validation failed", http.StatusBadRequest)
return
}

Se realiza la validation del DTO por medio del archivo

/dto/dto.go

package dto

import "github.com/go-playground/validator/v10"

type ProductCreateDTO struct {
    Nombre string  `json:"name" validate:"required,min=2,max=100"`
    Precio float64 `json:"price" validate:"required,gt=0"`
}

func (p *ProductCreateDTO) Validate() []*validator.FieldError {
    validate := validator.New()
    err := validate.Struct(p)
    if err != nil {
        var fieldErrors []*validator.FieldError

        if ve, ok := err.(validator.ValidationErrors); ok {
            for _, fe := range ve {
                fieldError := fe
                fieldErrors = append(fieldErrors, &fieldError)
            }
            return fieldErrors
        }
    }
    return nil
}

Enter fullscreen mode Exit fullscreen mode

type ProductCreateDTO struct {
Nombre string
json:"name" validate:"required,min=2,max=100"
Precio float64
json:"price" validate:"required,gt=0"
}

Usando una librera de validacion de datos github.com/go-playground/validator/v10, especificamos en los campos de la entidad Product por medio del struct ProductCreateDTO, los campos a validar con casos regulares de DTO como campos requeridos y tipos de datos

Bootstrap

En este archivo tendremos centralizado nuestros repositorios , servicios y controladores , ademas de encender la conexión de la base de datos, el cual sera el siguiente

/bootstrap/bootstrap.go

package bootstrap

import (
    "api-golang/config"
    "api-golang/controller"
    "api-golang/database"
    "api-golang/repositories"
    "api-golang/services"
    "log"

    "go.uber.org/dig"
    "gorm.io/gorm"
)

func BuildContainer(cfg config.Config) *dig.Container {
    container := dig.New()

    // Database Connection Registration
    container.Provide(func() (*gorm.DB, error) {
        return database.ConnectDatabase(cfg)
    })

    // Repositories
    if err := container.Provide(repositories.NewProductoRepository); err != nil {
        log.Fatalf("Failed to provide product repository: %v", err)
    }

    // Services
    if err := container.Provide(services.NewProductoService); err != nil {
        log.Fatalf("Failed to provide product service: %v", err)
    }

    // Controller
    if err := container.Provide(controller.NewProductController); err != nil {
        log.Fatalf("Failed to provide product controller: %v", err)
    }

    return container
}

Enter fullscreen mode Exit fullscreen mode

Es importante tener en un solo lugar, todas las incializaciones de los archivos a usar, si se requiere inicializar otros servicios, lo ideal es hacerlo en este archivo

Main

Finalmente en nuestro archivo main.go , tendremos todas las llamadas necesarias para iniciar nuestro backend, en este caso los importantes por los momentos es la inicializacion de las variables de entorno y de las capas definidas en la función BuildContainer de bootstrap como ademas de la definición final del route a usar para cada proposito

package main

import (
    "api-golang/bootstrap"
    "api-golang/config"
    "api-golang/controller"
    "log"
    "net/http"

    _ "github.com/golang-migrate/migrate/v4/database/postgres"
    _ "github.com/golang-migrate/migrate/v4/source/file"
)

func main() {
    cfg := config.LoadConfig()

    container := bootstrap.BuildContainer(cfg)

    // Invoke the container to inject the controller and configure the routes
    err := container.Invoke(func(productController *controller.ProductController) {
        http.HandleFunc("/productos", productController.GetProducts)
        http.HandleFunc("/producto", productController.CreateProduct)
    })

    if err != nil {
        log.Fatalf("Failed to invoke container: %v", err)
    }

    log.Println("Server started on port 8080")
    if err := http.ListenAndServe(":8080", nil); err != nil {
        log.Fatal(err)
    }
}
Enter fullscreen mode Exit fullscreen mode

Una ultima cosa y muy importante es tener siempre pruebas unitarias, para Go es mucho mas sencillo, podemos definir test solo con el sufijo _test, Go automáticamente los reconoce al correr el comando go test, para tener un orden de los test de nuestro backend, coloque un test del controlador, en la ruta

/controller/product_controller_test.go

package controller

import (
    "api-golang/dto"
    "api-golang/models"
    "api-golang/services/mocks"
    "bytes"
    "encoding/json"
    "net/http"
    "net/http/httptest"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/mock"
)

func TestCreateProduct(t *testing.T) {

    // Prepare de ProductService Interface
    mockService := new(mocks.ProductService)
    // Prepare de The Product Instance with data
    mockProduct := models.Product{Nombre: "Test Product", Precio: 10.99}
        // Prepare de The DTO with data to validate
    mockProductDTO := dto.ProductCreateDTO{Nombre: "Test Product", Precio: 10.99}

    // Mocking the service
    mockService.On("CreateProduct", mock.AnythingOfType("models.Product")).Return(mockProduct, nil)

    // Prepare the Controller and pass the Mock Service into the controller
    controller := NewProductController(mockService)

    // Http Request
    productJSON, _ := json.Marshal(mockProductDTO)
    req, _ := http.NewRequest("POST", "/product", bytes.NewBuffer(productJSON))
    rr := httptest.NewRecorder()

    // After the http request call we use the controller to use the CreateProduct function
    handler := http.HandlerFunc(controller.CreateProduct)
    handler.ServeHTTP(rr, req)

    assert.Equal(t, http.StatusOK, rr.Code)

    // Check the mock data vs response
    var returnedProduct models.Product
    json.Unmarshal(rr.Body.Bytes(), &returnedProduct)

    assert.Equal(t, mockProduct.Nombre, returnedProduct.Nombre)
    assert.Equal(t, mockProduct.Precio, returnedProduct.Precio)

    mockService.AssertExpectations(t)
}



Enter fullscreen mode Exit fullscreen mode

Un detalle super genial que vi con Go, es una herramienta llamada Mockery, la cual es una herramienta que nos ayuda a construir mocks de la interfaz del servicio ProductService, y automaticamente nos deja la logica necesaria a usar para poder realizar el test del controlador junto con la interfaz del servicio, para ejecutar automaticamente las interfaces necesarias puedes ejecutar:

mockery --name=ProductService --output=services/mocks --outpkg=mocks --case=underscore

luego, como tenemos nuestros test por cada archivo a usar, el comando para los test queda como go test ./...

Un ultimo detalle, es poder tener en nuestro desarrollo local una manera de observar nuestros cambios con hard-reload, para Go, hay una herramienta llamada Air la cual es excelente para iniciar nuestro servidor local, y que escuche los constantes cambios que tengamos en nuestro código, para usarlo simplemente lo configuramos con air init y lo iniciamos ejecutando por consola air

Image description

Para probar el endpoint seria http://localhost:8080/productos

Listo !

Conclusion

Tenemos un backend inicial listo para usar para nuestros proyectos, realizando un repaso sobre lo que se ha explicado en este backend tenemos lo siguiente

-Arquitectura de 3 capas Repositorios, Servicios y Controladores

  • Configuración de la base de datos
  • Se aplicó la inyección de dependencias para desacoplar las capas de la aplicación.
  • Se centralizaron las configuraciones utilizando variables de entorno, permitiendo que el proyecto sea fácilmente adaptable a diferentes entornos
  • Manejo de Errores
  • Validacion de entradas usando Data Transfer Object (DTO)
  • ORM para interactuar con la base de datos
  • Migraciones
  • Pruebas Unitarias
  • Se integraron herramientas de desarrollo como mockery para la generación de mocks y herramientas de terceros para la mejora del flujo de trabajo de desarrollo
  • Hot Reloading con AIR

Siempre es bueno compartir conocimientos a nuestra comunidad, es la primera vez que realizo un articulo :D, asi que espero que este backend inicial sea util para mis colegas!

Saludos!

Codigo: https://github.com/jorge6242/api-golang

Top comments (0)