Introducción al Microservicio de Productos
En esta lección, nos sumergiremos en el corazón de nuestro sistema de e-commerce: el microservicio de productos. Este componente es fundamental, ya que actúa como la fuente única de verdad para el catálogo de productos, manejando toda la información que los clientes ven y sobre la cual realizan sus decisiones de compra. Construiremos un servicio RESTful completo con operaciones CRUD (Crear, Leer, Actualizar, Eliminar) utilizando el router gorilla/mux, que nos ofrece un control granular sobre las rutas y un rendimiento excelente.
El enfoque será práctico y orientado a la producción. No solo definiremos estructuras y endpoints, sino que también integraremos una capa de persistencia de datos con una base de datos real (usaremos SQLite para simplicidad, pero el patrón se aplica a PostgreSQL o MySQL), validación de entradas, manejo robusto de errores y respuestas HTTP apropiadas. Este servicio será completamente independiente, sentando las bases para su posterior comunicación con otros microservicios como el de órdenes o usuarios.
Al finalizar, tendrás un servicio funcional que puede ser contenerizado y desplegado. Este proyecto integrador consolida conceptos clave de Go como structs, interfaces, manejo de HTTP, y el patrón de repositorio, aplicándolos a un contexto real de alto rendimiento.
Concepto Clave: Arquitectura por Capas y el Patrón Repositorio
Para construir un microservicio mantenible y testeable, es crucial separar las responsabilidades. Adoptaremos una arquitectura por capas clásica: Handler (Controlador), Service (Lógica de negocio), y Repository (Acceso a datos). El Handler se encarga únicamente de traducir las peticiones HTTP en llamadas a la capa de servicio y de formatear las respuestas HTTP. La capa de Service contiene la lógica de negocio específica de los productos (por ejemplo, aplicar impuestos, verificar disponibilidad). La capa Repository abstrae completamente la interacción con la base de datos.
El Patrón Repositorio es la estrella aquí. Imagina que tu base de datos es un gran almacén caótico. El repositorio actúa como un gestor de almacén experto. Tú, desde la lógica de negocio (Service), no necesitas saber si los datos están en estanterías de metal (SQL) o en cajas de plástico (NoSQL); solo le pides al gestor: "tráeme el producto con ID 123" o "guarda este nuevo producto". El gestor sabe exactamente cómo navegar el almacén para cumplir tu pedido. Esto permite cambiar el sistema de almacenamiento (de SQLite a PostgreSQL) sin tocar ni una línea de tu lógica de negocio.
Esta separación facilita enormemente las pruebas unitarias. Puedes probar la lógica de servicio con un repository mock (un gestor de almacén de mentira) sin necesidad de una base de datos real. Además, centraliza la lógica de consultas SQL y la gestión de conexiones, haciendo el código más seguro y consistente.
Tip de Producción: Invierte tiempo en diseñar una interfaz de repositorio limpia. Esto pagará dividendos enormes cuando necesites escalar, cambiar de base de datos o agregar caching (como Redis) como una capa intermedia entre el Service y el Repository.
Cómo Funciona en la Práctica: Flujo de una Petición CREATE
Vamos a seguir el viaje de una petición POST /api/v1/products que contiene los datos de un nuevo producto en formato JSON. Este ejemplo ilustra cómo las capas interactúan. Primero, el router gorilla/mux, que hemos configurado en nuestra función main, recibe la petición HTTP y la dirige al Handler de productos correspondiente, específicamente a su método CreateProduct.
Dentro del Handler, el primer paso es decodificar el cuerpo JSON de la petición en una struct de Go (por ejemplo, CreateProductRequest). Inmediatamente, realizamos una validación básica: ¿tiene nombre? ¿es un precio positivo? Si falla, respondemos inmediatamente con un código HTTP 400 (Bad Request). Si es válido, el Handler llama al método CreateProduct de la capa de Service, pasándole el objeto de dominio (no la request HTTP).
La capa de Service puede enriquecer el dato (por ejemplo, asignar un SKU único, establecer la fecha de creación) y luego invoca al método Create de la interfaz del Repository. El Repository concreto (por ejemplo, SQLiteRepository) traduce este objeto de dominio Go en una consulta SQL INSERT, la ejecuta contra la base de datos y devuelve el producto recién creado con su ID generado. Este resultado viaja de vuelta por la cadena (Repository -> Service -> Handler). Finalmente, el Handler codifica el producto final en JSON y responde con un HTTP 201 (Created), incluyendo la cabecera Location con la URL del nuevo recurso.
Código en Acción: Estructuras, Router y Handler Base
Comencemos definiendo las estructuras centrales de nuestro dominio y el esqueleto del handler. Nota cómo separamos la struct para la petición de creación de la struct del producto de dominio.
// domain/product.go
package domain
import "time"
// Product representa la entidad central de nuestro microservicio.
type Product struct {
ID string `json:"id"`
Name string `json:"name" validate:"required,min=3,max=100"`
Description string `json:"description" validate:"max=500"`
Price float64 `json:"price" validate:"required,gt=0"`
SKU string `json:"sku"` // Código único
Category string `json:"category" validate:"required"`
Stock int `json:"stock" validate:"gte=0"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// CreateProductRequest define el payload para crear un producto.
// Nota la ausencia de ID y timestamps, que serán generados por el sistema.
type CreateProductRequest struct {
Name string `json:"name" validate:"required,min=3,max=100"`
Description string `json:"description" validate:"max=500"`
Price float64 `json:"price" validate:"required,gt=0"`
Category string `json:"category" validate:"required"`
Stock int `json:"stock" validate:"gte=0"`
}
// UpdateProductRequest define el payload para actualizar un producto.
// Todos los campos son punteros para distinguir entre "no proporcionado" y "proporcionado con valor cero".
type UpdateProductRequest struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=3,max=100"`
Description *string `json:"description,omitempty" validate:"omitempty,max=500"`
Price *float64 `json:"price,omitempty" validate:"omitempty,gt=0"`
Category *string `json:"category,omitempty"`
Stock *int `json:"stock,omitempty" validate:"omitempty,gte=0"`
}
Ahora, definamos la interfaz del Repository y la estructura del Handler. Esta es la base de nuestra arquitectura.
// repository/product_repository.go
package repository
import "github.com/tu-usuario/product-service/domain"
// ProductRepository define el contrato que cualquier almacenamiento debe cumplir.
type ProductRepository interface {
GetAll() ([]domain.Product, error)
GetByID(id string) (*domain.Product, error)
Create(product *domain.Product) error
Update(id string, product *domain.Product) error
Delete(id string) error
}
// handler/product_handler.go
package handler
import (
"encoding/json"
"net/http"
"github.com/gorilla/mux"
"github.com/tu-usuario/product-service/domain"
"github.com/tu-usuario/product-service/service"
)
type ProductHandler struct {
service service.ProductService
}
func NewProductHandler(s service.ProductService) *ProductHandler {
return &ProductHandler{service: s}
}
// setupRouter en la función main (main.go)
func main() {
// 1. Inicializar Repositorio (ej: SQLite)
// repo := repository.NewSQLiteRepository("products.db")
// 2. Inicializar Servicio
// productService := service.NewProductService(repo)
// 3. Inicializar Handler
// productHandler := handler.NewProductHandler(productService)
r := mux.NewRouter()
api := r.PathPrefix("/api/v1").Subrouter()
// Definición de rutas
api.HandleFunc("/products", productHandler.GetAllProducts).Methods("GET")
api.HandleFunc("/products/{id}", productHandler.GetProduct).Methods("GET")
api.HandleFunc("/products", productHandler.CreateProduct).Methods("POST")
api.HandleFunc("/products/{id}", productHandler.UpdateProduct).Methods("PUT")
api.HandleFunc("/products/{id}", productHandler.DeleteProduct).Methods("DELETE")
// Middleware para logging y content-type JSON podrían ir aquí
http.Handle("/", r)
http.ListenAndServe(":8080", nil)
}
Implementación Completa del CRUD: Service y Repository SQLite
Profundicemos en la implementación concreta de la capa de Service y un Repository para SQLite. Observa cómo el Service orquesta la lógica y el Repository ejecuta el SQL.
// service/product_service.go
package service
import (
"errors"
"github.com/google/uuid"
"github.com/tu-usuario/product-service/domain"
"github.com/tu-usuario/product-service/repository"
"time"
)
type productService struct {
repo repository.ProductRepository
}
func NewProductService(repo repository.ProductRepository) *productService {
return &productService{repo: repo}
}
func (s *productService) GetAll() ([]domain.Product, error) {
return s.repo.GetAll()
}
func (s *productService) GetByID(id string) (*domain.Product, error) {
if id == "" {
return nil, errors.New("el ID del producto no puede estar vacío")
}
return s.repo.GetByID(id)
}
func (s *productService) Create(req domain.CreateProductRequest) (*domain.Product, error) {
// Validación de negocio podría ir aquí (ej: SKU único)
now := time.Now()
newProduct := &domain.Product{
ID: uuid.New().String(), // Generamos un UUID
Name: req.Name,
Description: req.Description,
Price: req.Price,
SKU: generateSKU(req.Name), // Función hipotética
Category: req.Category,
Stock: req.Stock,
CreatedAt: now,
UpdatedAt: now,
}
err := s.repo.Create(newProduct)
if err != nil {
return nil, err // El repo podría devolver error por duplicado, etc.
}
return newProduct, nil
}
func (s *productService) Update(id string, req domain.UpdateProductRequest) (*domain.Product, error) {
// 1. Obtener el producto existente
existingProduct, err := s.repo.GetByID(id)
if err != nil {
return nil, err // Producto no encontrado
}
// 2. Aplicar cambios solo a los campos proporcionados (patch parcial con PUT)
if req.Name != nil {
existingProduct.Name = *req.Name
}
if req.Description != nil {
existingProduct.Description = *req.Description
}
if req.Price != nil {
existingProduct.Price = *req.Price
}
if req.Category != nil {
existingProduct.Category = *req.Category
}
if req.Stock != nil {
existingProduct.Stock = *req.Stock
}
existingProduct.UpdatedAt = time.Now()
// 3. Persistir el cambio
err = s.repo.Update(id, existingProduct)
if err != nil {
return nil, err
}
return existingProduct, nil
}
func (s *productService) Delete(id string) error {
return s.repo.Delete(id)
}
// repository/sqlite_repository.go
package repository
import (
"database/sql"
"github.com/tu-usuario/product-service/domain"
_ "github.com/mattn/go-sqlite3" // Driver SQLite
)
type sqliteRepository struct {
db *sql.DB
}
func NewSQLiteRepository(dataSourceName string) (*sqliteRepository, error) {
db, err := sql.Open("sqlite3", dataSourceName)
if err != nil {
return nil, err
}
// Crear tabla si no existe
query := `CREATE TABLE IF NOT EXISTS products (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
price REAL NOT NULL,
sku TEXT UNIQUE,
category TEXT NOT NULL,
stock INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);`
_, err = db.Exec(query)
if err != nil {
return nil, err
}
return &sqliteRepository{db: db}, nil
}
func (r *sqliteRepository) Create(product *domain.Product) error {
query := `INSERT INTO products
(id, name, description, price, sku, category, stock, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
_, err := r.db.Exec(query,
product.ID, product.Name, product.Description, product.Price,
product.SKU, product.Category, product.Stock,
product.CreatedAt, product.UpdatedAt)
return err
}
func (r *sqliteRepository) GetByID(id string) (*domain.Product, error) {
query := `SELECT id, name, description, price, sku, category, stock, created_at, updated_at
FROM products WHERE id = ?`
row := r.db.QueryRow(query, id)
p := &domain.Product{}
err := row.Scan(&p.ID, &p.Name, &p.Description, &p.Price, &p.SKU,
&p.Category, &p.Stock, &p.CreatedAt, &p.UpdatedAt)
if err == sql.ErrNoRows {
return nil, nil // Producto no encontrado
}
if err != nil {
return nil, err
}
return p, nil
}
// Implementaciones para GetAll, Update y Delete seguirían un patrón similar...
Errores Comunes y Cómo Evitarlos
1. No Validar la Entrada del Usuario: Confiar ciegamente en el JSON recibido es una puerta abierta a datos corruptos, inyección SQL (aunque Go lo mitiga) y fallos en la lógica. Solución: Usa una librería de validación como go-playground/validator en las structs de request. Valida siempre en el handler antes de pasar los datos al service.
2. Exponer Errores Internos de la Base de Datos: Devolver el mensaje de error crudo de SQL (ej: "UNIQUE constraint failed: products.sku") en la respuesta HTTP revela detalles de implementación. Solución: Envuelve los errores del repositorio en la capa de servicio. Distingue entre "no encontrado" (404), "conflicto" (409 por SKU duplicado) y "error interno del servidor" (500) con mensajes genéricos apropiados para el cliente API.
3. Olvidar el Manejo de Concurrencia (Race Conditions):strong> En operaciones de actualización como incrementar stock, dos peticiones simultáneas podrían leer el mismo valor, incrementarlo y guardarlo, perdiendo una de las actualizaciones. Solución: Usa transacciones en la base de datos o construcciones atómicas como UPDATE products SET stock = stock - 1 WHERE id = ? AND stock >= 1. En Go, también puedes usar sync.Mutex a nivel de aplicación si es crítico, pero la base de datos suele ser la mejor guardiana.
4. Códigos de Estado HTTP Incorrectos: Devolver siempre 200 OK o 500 Internal Server Error es una mala práctica RESTful. Solución: Aprende y aplica los códigos correctos: 200 (GET exitoso), 201 (POST creado), 204 (DELETE exitoso sin cuerpo), 400 (petición mal formada), 404 (recurso no encontrado), 409 (conflicto), 422 (entidad no procesable por validación).
5. No Implementar Paginación, Filtrado y Ordenación en Endpoints GET: Un endpoint GET /products que devuelve todos los productos colapsará cuando el catálogo crezca. Solución: Diseña desde el inicio con parámetros de consulta como ?page=1&limit=20&category=electronics&sort=price y modifica tu repositorio para construir consultas SQL dinámicas de manera segura (evitando inyección).
Checklist de Dominio
- He definido estructuras de dominio (Product) claramente separadas de las estructuras de request/response (CreateProductRequest).
- He implementado las tres capas (Handler, Service, Repository) con responsabilidades bien delimitadas y las he conectado mediante inyección de dependencias.
- He escrito un repositorio concreto (ej: para SQLite) que implementa correctamente todas las operaciones CRUD usando el paquete database/sql y consultas parametrizadas.
- He implementado validación de datos de entrada en los handlers y manejo de errores apropiado, devolviendo códigos de estado HTTP RESTful correctos.
- He probado manualmente (con curl, Postman o similar) todos los endpoints: GET (uno y todos), POST, PUT, DELETE, verificando las respuestas y códigos de estado.
- He considerado y, al menos, diseñado la interfaz para funcionalidades de producción como paginación, filtrado y manejo seguro de concurrencia en actualizaciones.
- He asegurado que mi servicio sea autocontenido y pueda ser ejecutado independientemente, con su propia base de datos y configuración.
- He revisado que no haya datos sensibles o errores internos expuestos en las respuestas de la API.