La Capa de Repositorio en Go: Conexiones, Queries y Arquitectura Agnóstica
Una guía exhaustiva sobre cómo construir la capa de repositorio en Go: arquitectura hexagonal con ports, conexiones a MongoDB, PostgreSQL, SQLite, manejo de queries, validación, escalabilidad y cómo cambiar de base de datos sin tocar la lógica de negocio.
En todo sistema que trabaja con datos, existe un punto crítico: cómo accedes a esos datos. Es la diferencia entre un sistema que puedes mantener durante años y uno que se convierte en una pesadilla cuando necesitas cambiar de base de datos o escalar.
He visto código donde la lógica de negocio está mezclada con código de base de datos. Un cambio en el esquema de la BD requiere cambiar a través de toda la aplicación. Cambiar de MongoDB a PostgreSQL? Reescribir toda la aplicación. Agregar un caché? Imposible sin tocar todo el código.
Luego he visto sistemas bien diseñados donde la capa de repositorio está tan bien aislada que cambiar de base de datos es trivial. Agregar un caché es una línea de código. Pasar de un repositorio en memoria a PostgreSQL es cambiar una sola línea donde se crea la instancia.
La capa de repositorio es donde vive la abstracción más importante de tu arquitectura. Es donde proteges tu lógica de negocio del ruido tecnológico de las bases de datos.
Este artículo es una guía exhaustiva sobre cómo construir repositorios profesionales en Go 1.25. No es teoría. Es código real, patrones que funcionan en producción, y explicaciones claras de por qué cada decisión importa.
Parte 1: El Concepto Fundamental - Qué Es Un Repositorio
1.1 La Definición: Ilusión de Colecciones
Un repositorio es una abstracción que hace parecer que tu lógica de negocio está trabajando con colecciones en memoria, no con bases de datos reales.
Cuando tu lógica de negocio necesita un usuario, no debería escribir código SQL. No debería saber si los datos vienen de MongoDB, PostgreSQL, o un archivo JSON. Solo debería decir: “Dame el usuario con ID 123” y recibir ese usuario.
// Lo que tu lógica de negocio ve
user, err := userRepository.GetByID("user-123")
// El repositorio maneja:
// - Conectarse a la BD
// - Escribir la query correcta
// - Mapear resultados a Go structs
// - Manejar errores
// - Validar
// - Todo lo técnico
1.2 El Principio: Hexagonal Architecture
La clave es usar puertos (interfaces) y adaptadores:
Lógica de Negocio
↓
PUERTO: interface UserRepository
↑
ADAPTADORES (múltiples implementaciones):
├── MongoUserRepository (implementa puerto)
├── PostgresUserRepository (implementa puerto)
├── SQLiteUserRepository (implementa puerto)
└── MemoryUserRepository (para tests)
Tu lógica de negocio solo conoce el puerto (la interfaz). No conoce qué base de datos se usa. Puedes cambiar adaptadores sin tocar la lógica de negocio.
1.3 Beneficios Prácticos
Testabilidad:
// En tests, usas un repositorio en memoria
mockRepo := NewMemoryUserRepository()
service := NewUserService(mockRepo)
// Rápido, sin BD real, controlable
user, _ := service.GetUser("123")
assert(user.ID == "123")
Flexibilidad:
// Hoy: MongoDB
repo := NewMongoUserRepository(mongoClient)
// Mañana: PostgreSQL (sin cambiar UserService)
repo := NewPostgresUserRepository(pgDB)
// Pasado: Caché + Base de datos (combinación)
repo := NewCachedUserRepository(
NewPostgresUserRepository(pgDB),
cacheClient,
)
// MISMO código del servicio funciona con todos
Escalabilidad:
// Versión 1: Memoria
repo := NewMemoryUserRepository()
// Versión 2: SQLite (una BD en el filesystem)
repo := NewSQLiteUserRepository("app.db")
// Versión 3: PostgreSQL (servidor de BD)
repo := NewPostgresUserRepository(pgURL)
// Versión 4: MongoDB + Caché (escala distribuida)
repo := NewCachedUserRepository(
NewMongoUserRepository(mongoClient),
redisClient,
)
// Cada versión requiere SOLO cambiar esta línea
Parte 2: Definiendo El Puerto (La Interfaz)
2.1 El Patrón: Pequeñas, Focalizadas, Reutilizables
En lugar de una interfaz gigante llamada UserRepository con 50 métodos, defines interfaces pequeñas y focalizadas:
package domain
// Puerto 1: Obtener usuarios
type UserGetter interface {
GetByID(ctx context.Context, id string) (*User, error)
GetByEmail(ctx context.Context, email string) (*User, error)
}
// Puerto 2: Guardar usuarios
type UserSaver interface {
Create(ctx context.Context, user *User) error
Update(ctx context.Context, user *User) error
}
// Puerto 3: Eliminar usuarios
type UserDeleter interface {
Delete(ctx context.Context, id string) error
}
// Puerto 4: Listar usuarios (con filtrado)
type UserLister interface {
List(ctx context.Context, filters *UserFilters) ([]*User, error)
Count(ctx context.Context, filters *UserFilters) (int, error)
}
// Si necesitas todas las operaciones, combina interfaces
type UserRepository interface {
UserGetter
UserSaver
UserDeleter
UserLister
}
¿Por qué interfaces pequeñas?
- Responsabilidad única: Cada interfaz representa una operación clara
- Testing simple: Mockeás solo lo que necesitas
- Desacoplamiento: Tu código depende de lo mínimo necesario
- Flexibilidad: Puedes tener un repositorio que solo lee, otro que solo escribe
2.2 Contexto: El Patrón de Go Moderno
Nota que todos los métodos reciben context.Context como primer parámetro:
GetByID(ctx context.Context, id string) (*User, error)
¿Por qué? El contexto permite:
- Timeout: Si la BD tarda mucho, el contexto puede cancelar
- Cancelación: Si el usuario cierra la conexión, cancelas la query
- Tracing: Puedes rastrear requests a través de logs
- Valores: Pasar datos específicos de la request (user ID, request ID, etc)
// En tu servicio
func (us *UserService) GetUser(ctx context.Context, id string) (*User, error) {
// El contexto puede tener timeout
// ctx = context.WithTimeout(ctx, 5*time.Second)
return us.userRepo.GetByID(ctx, id)
}
// En tu repositorio
func (mr *MongoUserRepository) GetByID(ctx context.Context, id string) (*User, error) {
// Si ctx expira, esta query se cancela automáticamente
result := mr.collection.FindOne(ctx, bson.M{"_id": id})
// ...
}
2.3 Validación en El Puerto
El puerto también define qué tipo de datos esperas:
// Validaciones a nivel de interfaz
type User struct {
ID string `bson:"_id" validate:"required,uuid4"`
Email string `bson:"email" validate:"required,email"`
Name string `bson:"name" validate:"required,min=2,max=100"`
Status string `bson:"status" validate:"oneof=active inactive"`
Password string `bson:"-" validate:"required,min=8"` // No se persiste, solo se hashea
HashedPw string `bson:"hashed_pw" validate:"-"` // El hash si se persiste
}
// El puerto espera User válido
type UserSaver interface {
Create(ctx context.Context, user *User) error
}
// El adaptador valida ANTES de tocar la BD
func (mr *MongoUserRepository) Create(ctx context.Context, user *User) error {
// Validar el usuario
if err := user.Validate(); err != nil {
return err // Falla antes de tocar MongoDB
}
// Si llegamos aquí, sabemos que user es válido
result, err := mr.collection.InsertOne(ctx, user)
// ...
}
Parte 3: Adaptadores Concretos - Implementaciones Reales
3.1 Adaptador 1: MongoDB (NoSQL Flexible)
MongoDB es popular porque es flexible y rápido de desarrollar.
Setup Inicial
go get go.mongodb.org/mongo-driver
Tipo del Adaptador
package mongo
import (
"context"
"fmt"
"time"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
"myapp/internal/domain"
)
// MongoUserRepository implementa domain.UserRepository
type MongoUserRepository struct {
collection *mongo.Collection
db *mongo.Database
}
// NewMongoUserRepository crea una instancia
func NewMongoUserRepository(client *mongo.Client, dbName string) *MongoUserRepository {
return &MongoUserRepository{
collection: client.Database(dbName).Collection("users"),
db: client.Database(dbName),
}
}
Operación 1: Obtener Por ID
// Implementa domain.UserGetter
func (mr *MongoUserRepository) GetByID(ctx context.Context, id string) (*domain.User, error) {
// Crear contexto con timeout si no lo tiene
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 5*time.Second)
defer cancel()
}
// Construir el filtro
filter := bson.M{"_id": id}
// Ejecutar query
result := mr.collection.FindOne(ctx, filter)
// Manejar error: documento no encontrado es diferente a error de conexión
if result.Err() != nil {
if result.Err() == mongo.ErrNoDocuments {
return nil, domain.NewUserNotFoundError(id)
}
// Log del error real para debugging
fmt.Printf("MongoDB error al obtener usuario: %v\n", result.Err())
return nil, fmt.Errorf("error al obtener usuario: %w", result.Err())
}
// Desserializar a Go struct
var user domain.User
if err := result.Decode(&user); err != nil {
return nil, fmt.Errorf("error al descodificar usuario: %w", err)
}
return &user, nil
}
Operación 2: Obtener Por Email
func (mr *MongoUserRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 5*time.Second)
defer cancel()
}
filter := bson.M{"email": email}
result := mr.collection.FindOne(ctx, filter)
if result.Err() != nil {
if result.Err() == mongo.ErrNoDocuments {
return nil, domain.NewUserNotFoundError("email: " + email)
}
return nil, fmt.Errorf("error al obtener usuario por email: %w", result.Err())
}
var user domain.User
if err := result.Decode(&user); err != nil {
return nil, fmt.Errorf("error al descodificar usuario: %w", err)
}
return &user, nil
}
Operación 3: Crear
func (mr *MongoUserRepository) Create(ctx context.Context, user *domain.User) error {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 10*time.Second)
defer cancel()
}
// Validar ANTES de tocar la BD
if err := user.Validate(); err != nil {
return fmt.Errorf("usuario inválido: %w", err)
}
// Verificar que no existe otro con el mismo email
existing, err := mr.GetByEmail(ctx, user.Email)
if err == nil && existing != nil {
return domain.NewDuplicateEmailError(user.Email)
}
// Insertar
result, err := mr.collection.InsertOne(ctx, user)
if err != nil {
// MongoDB devuelve códigos de error específicos
if mongo.IsDuplicateKeyError(err) {
return domain.NewDuplicateEmailError(user.Email)
}
return fmt.Errorf("error al crear usuario: %w", err)
}
// Verificar que se insertó
if result.InsertedID == nil {
return fmt.Errorf("error: insertedID es nil")
}
return nil
}
Operación 4: Actualizar
func (mr *MongoUserRepository) Update(ctx context.Context, user *domain.User) error {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 10*time.Second)
defer cancel()
}
// Validar ANTES
if err := user.Validate(); err != nil {
return fmt.Errorf("usuario inválido: %w", err)
}
// Filtro: actualizar por ID
filter := bson.M{"_id": user.ID}
// Update: reemplazar documento
opts := options.Update().SetUpsert(false) // No crear si no existe
result, err := mr.collection.UpdateOne(
ctx,
filter,
bson.M{"$set": user},
opts,
)
if err != nil {
return fmt.Errorf("error al actualizar usuario: %w", err)
}
// Verificar que se actualizó algo
if result.MatchedCount == 0 {
return domain.NewUserNotFoundError(user.ID)
}
return nil
}
Operación 5: Listar Con Filtros
type UserFilters struct {
Status string // "active", "inactive", ""
Page int // Paginación
PageSize int
}
func (mr *MongoUserRepository) List(ctx context.Context, filters *UserFilters) ([]*domain.User, error) {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 10*time.Second)
defer cancel()
}
// Construir filtro
mongoFilter := bson.M{}
if filters != nil && filters.Status != "" {
mongoFilter["status"] = filters.Status
}
// Paginación
skip := int64(0)
limit := int64(50)
if filters != nil {
if filters.Page > 0 && filters.PageSize > 0 {
skip = int64((filters.Page - 1) * filters.PageSize)
limit = int64(filters.PageSize)
}
}
opts := options.Find().
SetSkip(skip).
SetLimit(limit).
SetSort(bson.M{"created_at": -1}) // Orden: más recientes primero
// Ejecutar query
cursor, err := mr.collection.Find(ctx, mongoFilter, opts)
if err != nil {
return nil, fmt.Errorf("error al listar usuarios: %w", err)
}
defer cursor.Close(ctx)
// Descodificar resultados
var users []*domain.User
if err = cursor.All(ctx, &users); err != nil {
return nil, fmt.Errorf("error al descodificar usuarios: %w", err)
}
// Si no hay resultados, devuelve slice vacío, no nil
if users == nil {
users = make([]*domain.User, 0)
}
return users, nil
}
Operación 6: Contar
func (mr *MongoUserRepository) Count(ctx context.Context, filters *UserFilters) (int, error) {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 5*time.Second)
defer cancel()
}
mongoFilter := bson.M{}
if filters != nil && filters.Status != "" {
mongoFilter["status"] = filters.Status
}
count, err := mr.collection.CountDocuments(ctx, mongoFilter)
if err != nil {
return 0, fmt.Errorf("error al contar usuarios: %w", err)
}
return int(count), nil
}
3.2 Adaptador 2: PostgreSQL (SQL Relacional)
PostgreSQL es robusto, escalable, y perfecto para sistemas complejos.
Setup Inicial
go get github.com/lib/pq
go get github.com/jmoiron/sqlx
Usamos sqlx que es una extensión de database/sql con mejor manejo de structs.
Tipo del Adaptador
package postgres
import (
"context"
"fmt"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"myapp/internal/domain"
)
type PostgresUserRepository struct {
db *sqlx.DB
}
func NewPostgresUserRepository(db *sqlx.DB) *PostgresUserRepository {
return &PostgresUserRepository{db: db}
}
Crear Tabla
// En tu migration/setup
const createTableSQL = `
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
status VARCHAR(50) DEFAULT 'active',
hashed_pw VARCHAR(255) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_email ON users(email);
CREATE INDEX idx_users_status ON users(status);
`
func (pr *PostgresUserRepository) CreateTable(ctx context.Context) error {
_, err := pr.db.ExecContext(ctx, createTableSQL)
return err
}
Operación 1: Obtener Por ID
func (pr *PostgresUserRepository) GetByID(ctx context.Context, id string) (*domain.User, error) {
query := `
SELECT id, email, name, status, hashed_pw, created_at, updated_at
FROM users
WHERE id = $1
`
var user domain.User
err := pr.db.GetContext(ctx, &user, query, id)
if err != nil {
if err == sql.ErrNoRows {
return nil, domain.NewUserNotFoundError(id)
}
return nil, fmt.Errorf("error al obtener usuario: %w", err)
}
return &user, nil
}
Operación 2: Obtener Por Email
func (pr *PostgresUserRepository) GetByEmail(ctx context.Context, email string) (*domain.User, error) {
query := `
SELECT id, email, name, status, hashed_pw, created_at, updated_at
FROM users
WHERE email = $1
`
var user domain.User
err := pr.db.GetContext(ctx, &user, query, email)
if err != nil {
if err == sql.ErrNoRows {
return nil, domain.NewUserNotFoundError("email: " + email)
}
return nil, fmt.Errorf("error al obtener usuario por email: %w", err)
}
return &user, nil
}
Operación 3: Crear
func (pr *PostgresUserRepository) Create(ctx context.Context, user *domain.User) error {
// Validar
if err := user.Validate(); err != nil {
return fmt.Errorf("usuario inválido: %w", err)
}
query := `
INSERT INTO users (id, email, name, status, hashed_pw, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
RETURNING id, created_at, updated_at
`
err := pr.db.QueryRowContext(ctx, query,
user.ID,
user.Email,
user.Name,
user.Status,
user.HashedPw,
).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt)
if err != nil {
// PostgreSQL maneja violaciones de constraint
if err.Error() == "pq: duplicate key value violates unique constraint \"users_email_key\"" {
return domain.NewDuplicateEmailError(user.Email)
}
return fmt.Errorf("error al crear usuario: %w", err)
}
return nil
}
Operación 4: Actualizar
func (pr *PostgresUserRepository) Update(ctx context.Context, user *domain.User) error {
if err := user.Validate(); err != nil {
return fmt.Errorf("usuario inválido: %w", err)
}
query := `
UPDATE users
SET email = $1,
name = $2,
status = $3,
hashed_pw = $4,
updated_at = CURRENT_TIMESTAMP
WHERE id = $5
`
result, err := pr.db.ExecContext(ctx, query,
user.Email,
user.Name,
user.Status,
user.HashedPw,
user.ID,
)
if err != nil {
return fmt.Errorf("error al actualizar usuario: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return domain.NewUserNotFoundError(user.ID)
}
return nil
}
Operación 5: Listar
func (pr *PostgresUserRepository) List(ctx context.Context, filters *UserFilters) ([]*domain.User, error) {
query := `
SELECT id, email, name, status, hashed_pw, created_at, updated_at
FROM users
WHERE 1=1
`
args := []interface{}{}
argCount := 1
// Agregar filtros dinámicamente
if filters != nil && filters.Status != "" {
query += fmt.Sprintf(" AND status = $%d", argCount)
args = append(args, filters.Status)
argCount++
}
// Orden y paginación
query += " ORDER BY created_at DESC"
if filters != nil && filters.PageSize > 0 {
query += fmt.Sprintf(" LIMIT $%d OFFSET $%d", argCount, argCount+1)
args = append(args, filters.PageSize, (filters.Page-1)*filters.PageSize)
}
var users []*domain.User
err := pr.db.SelectContext(ctx, &users, query, args...)
if err != nil {
return nil, fmt.Errorf("error al listar usuarios: %w", err)
}
if users == nil {
users = make([]*domain.User, 0)
}
return users, nil
}
3.3 Adaptador 3: SQLite (Archivo Local)
SQLite es perfecto para desarrollo, testing, y aplicaciones pequeñas.
Setup
go get github.com/mattn/go-sqlite3
Implementación Simplificada
package sqlite
import (
"context"
"database/sql"
"fmt"
"time"
_ "github.com/mattn/go-sqlite3"
"myapp/internal/domain"
)
type SQLiteUserRepository struct {
db *sql.DB
}
func NewSQLiteUserRepository(dbPath string) (*SQLiteUserRepository, error) {
db, err := sql.Open("sqlite3", dbPath)
if err != nil {
return nil, err
}
// Crear tabla si no existe
schema := `
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
status TEXT DEFAULT 'active',
hashed_pw TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`
if _, err := db.Exec(schema); err != nil {
return nil, err
}
return &SQLiteUserRepository{db: db}, nil
}
func (sr *SQLiteUserRepository) GetByID(ctx context.Context, id string) (*domain.User, error) {
query := `
SELECT id, email, name, status, hashed_pw, created_at, updated_at
FROM users
WHERE id = ?
`
var user domain.User
err := sr.db.QueryRowContext(ctx, query, id).Scan(
&user.ID,
&user.Email,
&user.Name,
&user.Status,
&user.HashedPw,
&user.CreatedAt,
&user.UpdatedAt,
)
if err != nil {
if err == sql.ErrNoRows {
return nil, domain.NewUserNotFoundError(id)
}
return nil, fmt.Errorf("error al obtener usuario: %w", err)
}
return &user, nil
}
// Similar para otros métodos...
Parte 4: Adaptador Composite - Caché + Base de Datos
En sistemas reales, frecuentemente combinas múltiples adaptadores:
package composite
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
"myapp/internal/domain"
)
// CachedUserRepository combina caché + base de datos
type CachedUserRepository struct {
cache *redis.Client
backend domain.UserRepository // El repositorio real (Mongo, Postgres, etc)
cacheTTL time.Duration
}
func NewCachedUserRepository(
cache *redis.Client,
backend domain.UserRepository,
ttl time.Duration,
) *CachedUserRepository {
return &CachedUserRepository{
cache: cache,
backend: backend,
cacheTTL: ttl,
}
}
func (cr *CachedUserRepository) GetByID(ctx context.Context, id string) (*domain.User, error) {
// 1. Intentar obtener del caché
cacheKey := fmt.Sprintf("user:%s", id)
cachedData, err := cr.cache.Get(ctx, cacheKey).Result()
if err == nil {
// Encontrado en caché
var user domain.User
// Deserializar JSON de Redis
json.Unmarshal([]byte(cachedData), &user)
return &user, nil
}
// 2. No está en caché, obtener de la BD
user, err := cr.backend.GetByID(ctx, id)
if err != nil {
return nil, err
}
// 3. Guardar en caché para futuras requests
if userJSON, err := json.Marshal(user); err == nil {
cr.cache.Set(ctx, cacheKey, string(userJSON), cr.cacheTTL)
}
return user, nil
}
func (cr *CachedUserRepository) Create(ctx context.Context, user *domain.User) error {
// Crear en BD
err := cr.backend.Create(ctx, user)
if err != nil {
return err
}
// Cachear el nuevo usuario
cacheKey := fmt.Sprintf("user:%s", user.ID)
if userJSON, err := json.Marshal(user); err == nil {
cr.cache.Set(ctx, cacheKey, string(userJSON), cr.cacheTTL)
}
return nil
}
func (cr *CachedUserRepository) Update(ctx context.Context, user *domain.User) error {
// Actualizar en BD
err := cr.backend.Update(ctx, user)
if err != nil {
return err
}
// Invalidar caché
cacheKey := fmt.Sprintf("user:%s", user.ID)
cr.cache.Del(ctx, cacheKey)
return nil
}
// Similar para Delete
Ahora puedes usar sin saber si hay caché o no:
// Con caché
cachedRepo := NewCachedUserRepository(
redisClient,
mongoRepo,
24*time.Hour,
)
service := NewUserService(cachedRepo)
// Sin caché
service := NewUserService(mongoRepo)
// MISMO código del servicio funciona en ambos casos
Parte 5: Manejo de Errores Profesional
5.1 Errores de Dominio vs Errores Técnicos
package domain
// Errores de dominio: significan algo para el negocio
type UserNotFoundError struct {
UserID string
}
func (e *UserNotFoundError) Error() string {
return fmt.Sprintf("usuario no encontrado: %s", e.UserID)
}
func NewUserNotFoundError(id string) *UserNotFoundError {
return &UserNotFoundError{UserID: id}
}
type DuplicateEmailError struct {
Email string
}
func (e *DuplicateEmailError) Error() string {
return fmt.Sprintf("email ya registrado: %s", e.Email)
}
func NewDuplicateEmailError(email string) *DuplicateEmailError {
return &DuplicateEmailError{Email: email}
}
5.2 Manejando Errores En El Adaptador
func (mr *MongoUserRepository) Create(ctx context.Context, user *domain.User) error {
// Validar
if err := user.Validate(); err != nil {
// Error de dominio
return err
}
result, err := mr.collection.InsertOne(ctx, user)
if err != nil {
// Errores técnicos: convertir a errores de dominio si es posible
if mongo.IsDuplicateKeyError(err) {
// Convertir error de BD a error de dominio
return domain.NewDuplicateEmailError(user.Email)
}
if err == context.DeadlineExceeded {
// Timeout
return fmt.Errorf("timeout al crear usuario: %w", err)
}
// Error desconocido: loguearlo y devolver genérico
log.Printf("MongoDB error al crear usuario: %v", err)
return fmt.Errorf("error al crear usuario")
}
return nil
}
5.3 En Tu Servicio: Manejando Ambos Tipos
func (us *UserService) RegisterUser(ctx context.Context, email, password string) (*User, error) {
// ... lógica de negocio ...
user := &User{ID: uuid.New().String(), Email: email}
if err := us.userRepo.Create(ctx, user); err != nil {
// Errores de dominio: devolver como están
if _, ok := err.(*domain.DuplicateEmailError); ok {
return nil, err // El cliente sabe qué hacer
}
// Otros errores: loguear y devolver genérico
log.Printf("Error creating user: %v", err)
return nil, fmt.Errorf("error al registrar usuario")
}
return user, nil
}
Parte 6: Mejores Prácticas
6.1 Usar Transacciones
func (pr *PostgresUserRepository) CreateUserWithProfile(
ctx context.Context,
user *domain.User,
profile *domain.UserProfile,
) error {
// Iniciar transacción
tx, err := pr.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback() // Si hay error, rollback automático
// 1. Crear usuario
userQuery := `INSERT INTO users (id, email) VALUES ($1, $2)`
if _, err := tx.ExecContext(ctx, userQuery, user.ID, user.Email); err != nil {
return err
}
// 2. Crear perfil
profileQuery := `INSERT INTO user_profiles (user_id, bio) VALUES ($1, $2)`
if _, err := tx.ExecContext(ctx, profileQuery, user.ID, profile.Bio); err != nil {
return err // Automáticamente rollback ambos cambios
}
// Si todo OK: commit
return tx.Commit()
}
6.2 Índices Para Rendimiento
// En tu setup inicial
// MongoDB
func (mr *MongoUserRepository) CreateIndexes(ctx context.Context) error {
indexModel := mongo.IndexModel{
Keys: bson.D{{Key: "email", Value: 1}},
Options: options.Index().SetUnique(true),
}
_, err := mr.collection.Indexes().CreateOne(ctx, indexModel)
return err
}
// PostgreSQL
func (pr *PostgresUserRepository) CreateIndexes(ctx context.Context) error {
indexes := []string{
`CREATE INDEX IF NOT EXISTS idx_users_email ON users(email)`,
`CREATE INDEX IF NOT EXISTS idx_users_status ON users(status)`,
`CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at DESC)`,
}
for _, idx := range indexes {
if _, err := pr.db.ExecContext(ctx, idx); err != nil {
return err
}
}
return nil
}
6.3 Logging Estructurado
import "log/slog"
func (mr *MongoUserRepository) GetByID(ctx context.Context, id string) (*domain.User, error) {
logger := slog.With("method", "GetByID", "user_id", id)
result := mr.collection.FindOne(ctx, bson.M{"_id": id})
if result.Err() != nil {
if result.Err() == mongo.ErrNoDocuments {
logger.WarnContext(ctx, "usuario no encontrado")
return nil, domain.NewUserNotFoundError(id)
}
logger.ErrorContext(ctx, "error al obtener usuario", "error", result.Err())
return nil, fmt.Errorf("error al obtener usuario: %w", result.Err())
}
var user domain.User
if err := result.Decode(&user); err != nil {
logger.ErrorContext(ctx, "error al descodificar", "error", err)
return nil, fmt.Errorf("error al descodificar usuario: %w", err)
}
logger.InfoContext(ctx, "usuario obtenido exitosamente")
return &user, nil
}
6.4 Connection Pooling
// MongoDB
client, err := mongo.Connect(ctx, options.Client().
ApplyURI(mongoURL).
SetMaxPoolSize(100).
SetMinPoolSize(10))
// PostgreSQL
dbConfig := fmt.Sprintf("postgres://user:pass@localhost/db")
db, err := sqlx.Open("postgres", dbConfig)
db.SetMaxOpenConns(25) // Máximo de conexiones abiertas
db.SetMaxIdleConns(10) // Máximo de conexiones en reposo
db.SetConnMaxLifetime(5 * time.Minute) // Vida máxima de conexión
Conclusión: El Poder de Abstracciones Correctas
Lo que aprendiste aquí es fundamental para arquitectura escalable:
✅ Puertos (Interfaces): Definen el contrato sin detalles técnicos
✅ Adaptadores: Implementan cómo acceder a datos específicamente
✅ Composición: Combina adaptadores para funcionalidad compleja (caché + BD)
✅ Errores: Convierte errores técnicos en errores de dominio
✅ Testing: Mocks triviales sin bases de datos reales
✅ Flexibilidad: Cambiar de base de datos es una línea de código
Cuando implementas la capa de repositorio correctamente, tu sistema puede:
- Empezar con SQLite para desarrollo
- Pasar a PostgreSQL en staging
- Escalar a MongoDB distribuido en producción
- Agregar Redis como caché sin reescribir
Todo esto sin tocar un una sola línea de tu lógica de negocio. Eso es el poder de una arquitectura bien diseñada.
Tags
Artículos relacionados
API Versioning Strategies: Cómo Evolucionar APIs sin Romper Clientes
Una guía exhaustiva sobre estrategias de versionado de APIs: URL versioning vs Header versioning, cómo deprecar endpoints sin shock, migration patterns reales, handling de cambios backwards-incompatibles, y decisiones arquitectónicas que importan. Con 50+ ejemplos de código en Go.
Arquitectura de software: Más allá del código
Una guía completa sobre arquitectura de software explicada en lenguaje humano: patrones, organización, estructura y cómo construir sistemas que escalen con tu negocio.
Automatizando tu vida con Go CLI: Guía profesional para crear herramientas de línea de comandos escalables
Una guía exhaustiva y paso a paso sobre cómo crear herramientas CLI escalables con Go 1.25.5: desde lo básico hasta proyectos empresariales complejos con flags, configuración, logging, y ejemplos prácticos para Windows y Linux.