Descubriendo la Potencia de Go: Backend Inicial con buenas practicas

Descubriendo la Potencia de Go: Backend Inicial con buenas practicas

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

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”),
}
}

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

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
}

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!”)
}

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

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
);

/migrations/1_create_products_table.down.sql

— +migrate Down
DROP TABLE IF EXISTS products;

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,
}
}

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
}

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
}

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
}

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
}

En este archivo aplicamos nuevamente una instancia en

type productoService struct {
repository repositories.ProductRepository
}

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
}

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

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
}

type ProductCreateDTO struct {
Nombre stringjson:”name” validate:”required,min=2,max=100″
Precio float64json:”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
}

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

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

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

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

Leave a Reply

Your email address will not be published. Required fields are marked *