La Capa de Repositorio en Go: Conexiones, Queries y Arquitectura Agnóstica

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.

Por Omar Flores

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?

  1. Responsabilidad única: Cada interfaz representa una operación clara
  2. Testing simple: Mockeás solo lo que necesitas
  3. Desacoplamiento: Tu código depende de lo mínimo necesario
  4. 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

#golang #repository-pattern #database #architecture #clean-architecture #go-1.25