Gestor de Notas Seguro en Go 1.25: Arquitectura Hexagonal desde Cero

Gestor de Notas Seguro en Go 1.25: Arquitectura Hexagonal desde Cero

La Guía Definitiva paso a paso para construir un gestor de notas empresarial con Go 1.25, Arquitectura Hexagonal pura, JWT, roles de usuario y permisos granulares. Desde la configuración de CachyOS hasta la inyección de dependencias. Diseñado para novatos y expertos.

Por Omar Flores

Gestor de Notas Seguro en Go 1.25: Arquitectura Hexagonal desde Cero

La Guía Definitiva para Ingenieros que Quieren Hacerlo Bien


🎯 Introducción: Por Qué Esta Guía

El Problema Real

Existe un abismo en la industria del software.

Por un lado, tenemos tutoriales superficiales que te enseñan a hacer un CRUD en 30 minutos con un framework mágico. Funciona. Pero cuando llega el momento de mantener el código, escalarlo o cambiar requisitos, todo se desmorona.

Por el otro lado, tenemos arquitectos puristas que hablan de arquitectura hexagonal, separación de capas e inyección de dependencias, pero sus ejemplos son tan abstractos que no sabes ni por dónde empezar.

Esta guía está diseñada para cerrar ese abismo.

¿Por Qué Go 1.25?

Go 1.25 no es solo “otro lenguaje”. Es una elección estratégica:

  • Simplicidad radical: Sin dependencias innecesarias. Sin framework mágico. Solo Go puro.
  • Rendimiento: Compila a binario nativo. No necesitas JVM o runtime pesado.
  • Concurrencia real: Goroutines y channels. Construido para sistemas modernos.
  • Estándar de facto: PostgreSQL, MongoDB, Redis… todos tienen drivers nativos en Go.
  • Industria: Kubernetes, Docker, Terraform, etcd, CockroachDB. Los sistemas críticos están hechos en Go.

Cuando uses esta guía, estarás aprendiendo las herramientas reales que usan empresas de nivel empresarial.

¿Por Qué Arquitectura Hexagonal?

Arquitectura Hexagonal (también llamada Puertos y Adaptadores) es el patrón más escalable para sistemas que deben crecer:

  • Independencia de decisiones técnicas: No estás acoplado a PostgreSQL o MongoDB. Puedes cambiar mañana.
  • Testeable al 100%: Sin mocks complejos. Solo interfaces simples.
  • Código que explica el negocio: El dominio está separado de la infraestructura. Se ve claramente qué es lógica de negocio y qué es técnica.
  • Fácil de onboarding: Los nuevos desarrolladores entienden la estructura inmediatamente.

Lo Que Construiremos

Un Gestor de Notas Empresarial con:

Usuarios y Roles:

  • Admin (control total)
  • Editor (crear y modificar notas)
  • Viewer (solo lectura)

Notas con Permisos Granulares:

  • Crear, leer, actualizar, eliminar notas
  • Compartir notas con otros usuarios con permisos específicos (View, Edit, Manage)
  • Filtrado y búsqueda avanzada

Seguridad Profesional:

  • JWT con access y refresh tokens
  • Passwords hasheadas con bcrypt
  • CORS configurado
  • Rate limiting
  • Validación de entrada

Infraestructura Real:

  • PostgreSQL para usuarios (datos relacionales)
  • MongoDB para notas (datos flexibles)
  • Docker Compose para ambiente local
  • Testing con HTTPie

🛠️ Fase 0: Configuración del Lab en CachyOS

Paso 1: Preparar el Sistema CachyOS

CachyOS es una distribución optimizada de Linux basada en Arch. Viene con optimizaciones de performance builtin. Perfecto para desarrollo.

Abre una terminal (siempre en CachyOS, en este caso):

# Actualizar el sistema
sudo pacman -Syu

# Instalar dependencias necesarias
sudo pacman -S base-devel git neovim docker docker-compose

Tip de CachyOS: En CachyOS, pacman viene preconfigurado con cachyos-pacman.conf que incluye optimizaciones de compilación. No necesitas hacer nada más.

Paso 2: Instalar Go 1.25

# Descargar Go 1.25 (o la versión más reciente)
cd /tmp
wget https://go.dev/dl/go1.25.linux-amd64.tar.gz

# Instalar
sudo rm -rf /usr/local/go
sudo tar -C /usr/local -xzf go1.25.linux-amd64.tar.gz

# Verificar instalación
go version

Output esperado:

go version go1.25 linux/amd64

Paso 3: Crear el Workspace

# Crear directorio del proyecto
mkdir -p ~/projects/notes-api && cd ~/projects/notes-api

# Inicializar módulo Go
go mod init github.com/tuusuario/notes-api

# Crear estructura de carpetas
mkdir -p cmd/api
mkdir -p internal/domain/{entity,valueobject,repository}
mkdir -p internal/usecase/{auth,note}
mkdir -p internal/adapter/{handler/middleware,repository/{postgres,mongo}}
mkdir -p internal/infrastructure/{config,database,jwt}
mkdir -p pkg/{errors,response}
mkdir -p test/{unit,integration}
mkdir -p scripts
mkdir -p docs

Verificar estructura:

tree -L 3 .

Output esperado:

.
├── cmd
│   └── api
├── internal
│   ├── adapter
│   │   ├── handler
│   │   │   └── middleware
│   │   └── repository
│   │       ├── mongo
│   │       └── postgres
│   ├── domain
│   │   ├── entity
│   │   ├── repository
│   │   └── valueobject
│   ├── infrastructure
│   │   ├── config
│   │   ├── database
│   │   └── jwt
│   └── usecase
│       ├── auth
│       └── note
├── pkg
│   ├── errors
│   └── response
├── scripts
├── test
│   ├── integration
│   └── unit
├── docs
├── go.mod
└── go.sum

Paso 4: Configurar Neovim para Go Development

Abre Neovim en el directorio del proyecto:

nvim .

Dentro de Neovim:

  • Presiona Esc para entrar en modo normal
  • Digita: :e ~/.config/nvim/init.lua y presiona Enter para editar la configuración

Agrega esta configuración mínima para Go:

-- ~/.config/nvim/init.lua

-- Configuración básica
vim.opt.number = true
vim.opt.expandtab = true
vim.opt.shiftwidth = 4
vim.opt.tabstop = 4
vim.opt.autoindent = true
vim.opt.smartindent = true

-- Keymaps
vim.keymap.set('n', '<leader>ff', ':Telescope find_files<CR>', { noremap = true })
vim.keymap.set('n', '<leader>fg', ':Telescope live_grep<CR>', { noremap = true })
vim.keymap.set('n', 'gd', vim.lsp.buf.definition, { noremap = true })
vim.keymap.set('n', 'K', vim.lsp.buf.hover, { noremap = true })
vim.keymap.set('n', '<leader>rn', vim.lsp.buf.rename, { noremap = true })

Tips de Neovim para esta Guía:

  1. Navegación rápida entre archivos:

    :Telescope find_files
    " O presionar <leader>ff
  2. Búsqueda en el código:

    :Telescope live_grep "User"
    " O presionar <leader>fg
  3. Crear un archivo nuevo sin salir de Neovim:

    :e internal/domain/entity/user.go
    " Neovim crea el archivo automáticamente
  4. Ejecutar comandos sin salir de Neovim:

    :!go run cmd/api/main.go

Paso 5: Instalar dependencias básicas de Go

En la terminal (en el directorio del proyecto):

# Instalar dependencias que usaremos más adelante
go get github.com/google/uuid              # UUIDs
go get golang.org/x/crypto/bcrypt          # Hashing de passwords
go get github.com/golang-jwt/jwt/v5        # JWT
go get github.com/lib/pq                   # PostgreSQL driver
go get go.mongodb.org/mongo-driver         # MongoDB driver

Verificar que se instalaron:

go mod tidy

Este comando limpia y organiza el go.mod.

Paso 6: Configurar Docker Compose

Crea un archivo docker-compose.yml en la raíz del proyecto:

nvim docker-compose.yml

Contenido:

version: "3.8"

services:
  # PostgreSQL para datos de usuarios
  postgres:
    image: postgres:16-alpine
    container_name: notes-postgres
    environment:
      POSTGRES_USER: postgres
      POSTGRES_PASSWORD: postgres
      POSTGRES_DB: notes_users
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    networks:
      - notes-network
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  # MongoDB para datos de notas
  mongodb:
    image: mongo:7.0-alpine
    container_name: notes-mongodb
    environment:
      MONGO_INITDB_ROOT_USERNAME: admin
      MONGO_INITDB_ROOT_PASSWORD: admin
      MONGO_INITDB_DB: notes_db
    ports:
      - "27017:27017"
    volumes:
      - mongo_data:/data/db
    networks:
      - notes-network
    healthcheck:
      test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:
  mongo_data:

networks:
  notes-network:
    driver: bridge

Levantar los servicios:

docker-compose up -d

Verificar que estén corriendo:

docker-compose ps

Output esperado:

NAME                COMMAND                  SERVICE             STATUS
notes-postgres      "docker-entrypoint.s…"   postgres            Up 2 seconds
notes-mongodb       "mongosh --version"      mongodb             Up 3 seconds

Paso 7: Crear archivo .env para configuración

nvim .env

Contenido:

# Server
SERVER_PORT=8080
SERVER_ENV=development

# PostgreSQL
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DATABASE=notes_users
POSTGRES_SSLMODE=disable

# MongoDB
MONGODB_URI=mongodb://admin:admin@localhost:27017/notes_db?authSource=admin

# JWT
JWT_SECRET=tu_secreto_super_seguro_aqui_cambiar_en_produccion
JWT_EXPIRATION=24h
JWT_REFRESH_EXPIRATION=7d

# App
CORS_ORIGINS=http://localhost:3000,http://localhost:5173
RATE_LIMIT_REQUESTS=1000
RATE_LIMIT_WINDOW=1m

⚠️ Importante: Nunca commits el .env a Git. Añade a .gitignore:

echo ".env" >> .gitignore

Paso 8: Verificar que todo funciona

# Compilar el proyecto (vacío, pero sin errores)
go build ./...

# Ejecutar go mod tidy
go mod tidy

# Verificar que las dependencias se instalaron
go list -m all

✅ Punto de Verificación: Lab Configurado

Antes de continuar a Fase 1: El Corazón (Domain), verifica que tienes:

  • Go 1.25 instalado
  • Estructura de carpetas creada
  • go.mod inicializado
  • Neovim configurado
  • Docker y Docker Compose instalados
  • PostgreSQL y MongoDB corriendo en Docker
  • .env creado
  • Dependencias de Go instaladas

Si todo está ✅, estamos listos para la Fase 1.



🏛️ Fase 1: El Corazón (Domain) - Value Objects y Entities

¿Qué es el Dominio?

El dominio es el núcleo de tu aplicación. Contiene:

  • Entidades: Objetos con identidad única (User, Note)
  • Value Objects: Objetos sin identidad, definidos por sus valores (Email, Password, Role)
  • Reglas de Negocio: Lógica fundamental que debe cumplirse siempre

Regla de Oro: El dominio NUNCA debe depender de nada externo (sin imports de HTTP, DB, frameworks).

Paso 1: Crear el Sistema de Errores Personalizados

Abre Neovim:

nvim internal/infrastructure/config/config.go

Primero, vamos a crear archivos de utilidad que usaremos en el dominio:

nvim pkg/errors/errors.go

Contenido de pkg/errors/errors.go:

package errors

import (
	"errors"
	"fmt"
)

// ErrorType representa el tipo de error
type ErrorType string

const (
	ErrorTypeValidation   ErrorType = "VALIDATION_ERROR"
	ErrorTypeNotFound     ErrorType = "NOT_FOUND"
	ErrorTypeUnauthorized ErrorType = "UNAUTHORIZED"
	ErrorTypeForbidden    ErrorType = "FORBIDDEN"
	ErrorTypeConflict     ErrorType = "CONFLICT"
	ErrorTypeInternal     ErrorType = "INTERNAL_ERROR"
)

// AppError es un error personalizado de la aplicación
type AppError struct {
	Type    ErrorType
	Message string
	Err     error
}

// Error implementa la interfaz error
func (e *AppError) Error() string {
	if e.Err != nil {
		return fmt.Sprintf("%s: %s (detail: %v)", e.Type, e.Message, e.Err)
	}
	return fmt.Sprintf("%s: %s", e.Type, e.Message)
}

// Unwrap permite usar errors.Is y errors.As
func (e *AppError) Unwrap() error {
	return e.Err
}

// NewValidationError crea un error de validación
func NewValidationError(message string) *AppError {
	return &AppError{
		Type:    ErrorTypeValidation,
		Message: message,
	}
}

// NewNotFoundError crea un error de recurso no encontrado
func NewNotFoundError(resource string) *AppError {
	return &AppError{
		Type:    ErrorTypeNotFound,
		Message: fmt.Sprintf("%s not found", resource),
	}
}

// NewUnauthorizedError crea un error de no autorizado
func NewUnauthorizedError(message string) *AppError {
	return &AppError{
		Type:    ErrorTypeUnauthorized,
		Message: message,
	}
}

// NewForbiddenError crea un error de prohibido
func NewForbiddenError(message string) *AppError {
	return &AppError{
		Type:    ErrorTypeForbidden,
		Message: message,
	}
}

// NewConflictError crea un error de conflicto
func NewConflictError(message string) *AppError {
	return &AppError{
		Type:    ErrorTypeConflict,
		Message: message,
	}
}

// NewInternalError crea un error interno
func NewInternalError(message string, err error) *AppError {
	return &AppError{
		Type:    ErrorTypeInternal,
		Message: message,
		Err:     err,
	}
}

// IsValidationError verifica si es error de validación
func IsValidationError(err error) bool {
	var appErr *AppError
	return errors.As(err, &appErr) && appErr.Type == ErrorTypeValidation
}

// IsNotFoundError verifica si es error de no encontrado
func IsNotFoundError(err error) bool {
	var appErr *AppError
	return errors.As(err, &appErr) && appErr.Type == ErrorTypeNotFound
}

// IsUnauthorizedError verifica si es error de no autorizado
func IsUnauthorizedError(err error) bool {
	var appErr *AppError
	return errors.As(err, &appErr) && appErr.Type == ErrorTypeUnauthorized
}

🎓 Explicación:

  • ErrorType: Constantes que representan tipos de error
  • AppError: Struct que encapsula tipo, mensaje y error subyacente
  • Unwrap(): Permite usar errors.Is() y errors.As() de Go 1.13+
  • Constructores: Funciones helper para crear errores específicos
  • Verificadores: Funciones para comprobar tipos de error

💡 Tip de Go: Go tiene excelente manejo de errores con errors.Is() y errors.As(). Úsalos en lugar de comparaciones directas.

Paso 2: Crear Value Objects

Los Value Objects son objetos inmutables definidos por sus valores, no por su identidad.

Email Value Object

nvim internal/domain/valueobject/email.go

Contenido:

package valueobject

import (
	"fmt"
	"regexp"
	"strings"

	apperrors "github.com/tuusuario/notes-api/pkg/errors"
)

// Email representa una dirección de email válida y normalizada
type Email struct {
	value string
}

// emailRegex es la expresión regular para validar emails
var emailRegex = regexp.MustCompile(`^[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$`)

// NewEmail crea un nuevo Email validado
func NewEmail(email string) (Email, error) {
	// Normalizar: trimear espacios y convertir a minúsculas
	normalized := strings.ToLower(strings.TrimSpace(email))

	// Validar que no está vacío
	if normalized == "" {
		return Email{}, apperrors.NewValidationError("email cannot be empty")
	}

	// Validar formato
	if !emailRegex.MatchString(normalized) {
		return Email{}, apperrors.NewValidationError("email format is invalid")
	}

	// Validar longitud
	if len(normalized) > 254 {
		return Email{}, apperrors.NewValidationError("email is too long (max 254 characters)")
	}

	return Email{value: normalized}, nil
}

// String retorna el valor del email
func (e Email) String() string {
	return e.value
}

// Value retorna el email como string (para debugging)
func (e Email) Value() string {
	return e.value
}

// Equals compara dos emails
func (e Email) Equals(other Email) bool {
	return e.value == other.value
}

// Domain retorna el dominio del email (ejemplo: "gmail.com" en "user@gmail.com")
func (e Email) Domain() string {
	parts := strings.Split(e.value, "@")
	if len(parts) == 2 {
		return parts[1]
	}
	return ""
}

🎓 Explicación:

  • Inmutabilidad: El campo value es privado
  • Validación en Constructor: NewEmail() valida antes de crear
  • Normalización: Trimea espacios y convierte a minúsculas
  • Regex: Valida formato de email estándar
  • Métodos de utilidad: Domain() para extraer parte del dominio

Password Value Object

nvim internal/domain/valueobject/password.go

Contenido:

package valueobject

import (
	"unicode"

	"golang.org/x/crypto/bcrypt"

	apperrors "github.com/tuusuario/notes-api/pkg/errors"
)

// Password representa una contraseña hasheada y validada
type Password struct {
	hash string
}

const (
	minPasswordLength = 8
	maxPasswordLength = 128
	bcryptCost        = 12
)

// NewPassword crea una nueva contraseña desde texto plano
func NewPassword(plainPassword string) (Password, error) {
	// Validar longitud
	if len(plainPassword) < minPasswordLength {
		return Password{}, apperrors.NewValidationError(
			"password must be at least 8 characters",
		)
	}

	if len(plainPassword) > maxPasswordLength {
		return Password{}, apperrors.NewValidationError(
			"password must not exceed 128 characters",
		)
	}

	// Validar complejidad
	if err := validatePasswordComplexity(plainPassword); err != nil {
		return Password{}, err
	}

	// Hashear la contraseña
	hash, err := bcrypt.GenerateFromPassword(
		[]byte(plainPassword),
		bcryptCost,
	)
	if err != nil {
		return Password{}, apperrors.NewInternalError(
			"failed to hash password",
			err,
		)
	}

	return Password{hash: string(hash)}, nil
}

// NewPasswordFromHash crea una contraseña desde un hash existente
// Se usa para cargar contraseñas desde la base de datos
func NewPasswordFromHash(hash string) Password {
	return Password{hash: hash}
}

// Hash retorna el hash bcrypt de la contraseña
func (p Password) Hash() string {
	return p.hash
}

// Compare verifica si una contraseña en texto plano coincide con el hash
func (p Password) Compare(plainPassword string) error {
	err := bcrypt.CompareHashAndPassword([]byte(p.hash), []byte(plainPassword))
	if err != nil {
		return apperrors.NewUnauthorizedError("password is incorrect")
	}
	return nil
}

// validatePasswordComplexity valida que la contraseña tenga:
// - Al menos una mayúscula
// - Al menos una minúscula
// - Al menos un número
// - Al menos un carácter especial
func validatePasswordComplexity(password string) error {
	var (
		hasUpper   = false
		hasLower   = false
		hasDigit   = false
		hasSpecial = false
	)

	for _, char := range password {
		switch {
		case unicode.IsUpper(char):
			hasUpper = true
		case unicode.IsLower(char):
			hasLower = true
		case unicode.IsDigit(char):
			hasDigit = true
		case unicode.IsPunct(char) || unicode.IsSymbol(char):
			hasSpecial = true
		}
	}

	if !hasUpper {
		return apperrors.NewValidationError(
			"password must contain at least one uppercase letter",
		)
	}

	if !hasLower {
		return apperrors.NewValidationError(
			"password must contain at least one lowercase letter",
		)
	}

	if !hasDigit {
		return apperrors.NewValidationError(
			"password must contain at least one digit",
		)
	}

	if !hasSpecial {
		return apperrors.NewValidationError(
			"password must contain at least one special character",
		)
	}

	return nil
}

🎓 Explicación:

  • Bcrypt: Hashing seguro con costo configurable (12 es bueno para 2026)
  • Validación de Complejidad: Requiere mayúsculas, minúsculas, números y caracteres especiales
  • Compare(): Compara contraseña en texto plano con hash seguro
  • NewPasswordFromHash(): Para cargar desde BD sin rehasheaning

Role Value Object

nvim internal/domain/valueobject/role.go

Contenido:

package valueobject

import (
	apperrors "github.com/tuusuario/notes-api/pkg/errors"
)

// Role representa el rol de un usuario en el sistema
type Role string

const (
	RoleAdmin    Role = "admin"
	RoleEditor   Role = "editor"
	RoleViewer   Role = "viewer"
)

// AllRoles devuelve todos los roles válidos
func AllRoles() []Role {
	return []Role{RoleAdmin, RoleEditor, RoleViewer}
}

// NewRole crea un nuevo Role validado
func NewRole(role string) (Role, error) {
	r := Role(role)

	if err := r.Validate(); err != nil {
		return "", err
	}

	return r, nil
}

// Validate verifica que el rol sea válido
func (r Role) Validate() error {
	for _, valid := range AllRoles() {
		if r == valid {
			return nil
		}
	}

	return apperrors.NewValidationError("invalid role")
}

// String retorna el rol como string
func (r Role) String() string {
	return string(r)
}

// IsAdmin verifica si el rol es admin
func (r Role) IsAdmin() bool {
	return r == RoleAdmin
}

// IsEditor verifica si el rol es editor
func (r Role) IsEditor() bool {
	return r == RoleEditor
}

// IsViewer verifica si el rol es viewer
func (r Role) IsViewer() bool {
	return r == RoleViewer
}

// CanManageUsers verifica si el rol puede gestionar usuarios
func (r Role) CanManageUsers() bool {
	return r == RoleAdmin
}

// CanCreateNotes verifica si el rol puede crear notas
func (r Role) CanCreateNotes() bool {
	return r == RoleAdmin || r == RoleEditor
}

// CanEditNotes verifica si el rol puede editar cualquier nota
func (r Role) CanEditNotes() bool {
	return r == RoleAdmin
}

// CanViewNotes verifica si el rol puede ver notas (todos pueden)
func (r Role) CanViewNotes() bool {
	return true
}

🎓 Explicación:

  • Constantes: Define roles válidos del sistema
  • Validación: NewRole() valida que sea un rol conocido
  • Predicados: Métodos como IsAdmin(), CanCreateNotes() para verificar permisos
  • Seguridad: Los permisos se definen en el Value Object, no esparcidos por el código

Permission Value Object (Para Compartir Notas)

nvim internal/domain/valueobject/permission.go

Contenido:

package valueobject

import (
	apperrors "github.com/tuusuario/notes-api/pkg/errors"
)

// Permission representa los permisos para acceder a una nota compartida
type Permission string

const (
	PermissionView   Permission = "view"    // Solo lectura
	PermissionEdit   Permission = "edit"    // Lectura y escritura
	PermissionManage Permission = "manage"  // Lectura, escritura y compartir
)

// AllPermissions devuelve todos los permisos válidos
func AllPermissions() []Permission {
	return []Permission{PermissionView, PermissionEdit, PermissionManage}
}

// NewPermission crea un nuevo Permission validado
func NewPermission(perm string) (Permission, error) {
	p := Permission(perm)

	if err := p.Validate(); err != nil {
		return "", err
	}

	return p, nil
}

// Validate verifica que el permiso sea válido
func (p Permission) Validate() error {
	for _, valid := range AllPermissions() {
		if p == valid {
			return nil
		}
	}

	return apperrors.NewValidationError("invalid permission")
}

// String retorna el permiso como string
func (p Permission) String() string {
	return string(p)
}

// CanView verifica si incluye permiso de lectura
func (p Permission) CanView() bool {
	return p == PermissionView || p == PermissionEdit || p == PermissionManage
}

// CanEdit verifica si incluye permiso de edición
func (p Permission) CanEdit() bool {
	return p == PermissionEdit || p == PermissionManage
}

// CanManage verifica si incluye permiso de gestión (compartir)
func (p Permission) CanManage() bool {
	return p == PermissionManage
}

// IsSupersetOf verifica si este permiso es superset de otro
// Ejemplo: "manage" es superset de "edit" y "view"
func (p Permission) IsSupersetOf(other Permission) bool {
	if p == other {
		return true
	}

	switch p {
	case PermissionManage:
		return true
	case PermissionEdit:
		return other == PermissionView
	default:
		return false
	}
}

🎓 Explicación:

  • Jerarquía de Permisos: manage > edit > view
  • Predicados: Métodos para verificar qué puede hacer
  • IsSupersetOf(): Verifica relaciones entre permisos

Paso 3: Crear Entidades

Las Entidades son objetos con identidad única. A diferencia de Value Objects, dos entidades con los mismos datos son diferentes si tienen IDs distintos.

Entity User

nvim internal/domain/entity/user.go

Contenido:

package entity

import (
	"time"

	"github.com/google/uuid"

	"github.com/tuusuario/notes-api/internal/domain/valueobject"
	apperrors "github.com/tuusuario/notes-api/pkg/errors"
)

// User representa un usuario en el sistema
type User struct {
	id        uuid.UUID
	email     valueobject.Email
	password  valueobject.Password
	name      string
	role      valueobject.Role
	createdAt time.Time
	updatedAt time.Time
	deletedAt *time.Time // soft delete
}

// NewUser crea un nuevo usuario
func NewUser(
	email valueobject.Email,
	password valueobject.Password,
	name string,
	role valueobject.Role,
) (*User, error) {
	// Validar nombre
	if err := validateUserName(name); err != nil {
		return nil, err
	}

	return &User{
		id:        uuid.New(),
		email:     email,
		password:  password,
		name:      name,
		role:      role,
		createdAt: time.Now().UTC(),
		updatedAt: time.Now().UTC(),
	}, nil
}

// ID retorna el ID del usuario
func (u *User) ID() uuid.UUID {
	return u.id
}

// Email retorna el email del usuario
func (u *User) Email() valueobject.Email {
	return u.email
}

// Password retorna la contraseña del usuario
func (u *User) Password() valueobject.Password {
	return u.password
}

// Name retorna el nombre del usuario
func (u *User) Name() string {
	return u.name
}

// Role retorna el rol del usuario
func (u *User) Role() valueobject.Role {
	return u.role
}

// CreatedAt retorna la fecha de creación
func (u *User) CreatedAt() time.Time {
	return u.createdAt
}

// UpdatedAt retorna la fecha de última actualización
func (u *User) UpdatedAt() time.Time {
	return u.updatedAt
}

// IsDeleted verifica si el usuario ha sido eliminado (soft delete)
func (u *User) IsDeleted() bool {
	return u.deletedAt != nil
}

// UpdateName actualiza el nombre del usuario
func (u *User) UpdateName(newName string) error {
	if err := validateUserName(newName); err != nil {
		return err
	}

	u.name = newName
	u.updatedAt = time.Now().UTC()
	return nil
}

// UpdateEmail actualiza el email del usuario
func (u *User) UpdateEmail(newEmail valueobject.Email) error {
	u.email = newEmail
	u.updatedAt = time.Now().UTC()
	return nil
}

// UpdateRole actualiza el rol del usuario
func (u *User) UpdateRole(newRole valueobject.Role) error {
	u.role = newRole
	u.updatedAt = time.Now().UTC()
	return nil
}

// SoftDelete marca el usuario como eliminado sin borrar datos
func (u *User) SoftDelete() {
	now := time.Now().UTC()
	u.deletedAt = &now
	u.updatedAt = now
}

// validateUserName valida que el nombre sea válido
func validateUserName(name string) error {
	if name == "" {
		return apperrors.NewValidationError("name cannot be empty")
	}

	if len(name) < 2 {
		return apperrors.NewValidationError("name must be at least 2 characters")
	}

	if len(name) > 100 {
		return apperrors.NewValidationError("name must not exceed 100 characters")
	}

	return nil
}

🎓 Explicación:

  • ID único: Cada usuario tiene un UUID
  • Getters privados: Los campos son privados, acceso mediante getters
  • Métodos de cambio: UpdateName(), UpdateRole() incluyen validación
  • Soft Delete: deletedAt permite borrado lógico
  • Timestamps: createdAt y updatedAt para auditoría

Entity Note

nvim internal/domain/entity/note.go

Contenido:

package entity

import (
	"time"

	"github.com/google/uuid"

	"github.com/tuusuario/notes-api/internal/domain/valueobject"
	apperrors "github.com/tuusuario/notes-api/pkg/errors"
)

// SharedWith representa un usuario con quien se compartió la nota
type SharedWith struct {
	UserID    uuid.UUID
	Permission valueobject.Permission
	SharedAt   time.Time
}

// Note representa una nota en el sistema
type Note struct {
	id        uuid.UUID
	ownerID   uuid.UUID
	title     string
	content   string
	tags      []string
	shared    []SharedWith
	createdAt time.Time
	updatedAt time.Time
	deletedAt *time.Time
}

// NewNote crea una nueva nota
func NewNote(
	ownerID uuid.UUID,
	title string,
	content string,
	tags []string,
) (*Note, error) {
	// Validaciones
	if err := validateTitle(title); err != nil {
		return nil, err
	}

	if err := validateContent(content); err != nil {
		return nil, err
	}

	if err := validateTags(tags); err != nil {
		return nil, err
	}

	return &Note{
		id:        uuid.New(),
		ownerID:   ownerID,
		title:     title,
		content:   content,
		tags:      tags,
		shared:    []SharedWith{},
		createdAt: time.Now().UTC(),
		updatedAt: time.Now().UTC(),
	}, nil
}

// ID retorna el ID de la nota
func (n *Note) ID() uuid.UUID {
	return n.id
}

// OwnerID retorna el ID del propietario
func (n *Note) OwnerID() uuid.UUID {
	return n.ownerID
}

// Title retorna el título
func (n *Note) Title() string {
	return n.title
}

// Content retorna el contenido
func (n *Note) Content() string {
	return n.content
}

// Tags retorna las etiquetas
func (n *Note) Tags() []string {
	return n.tags
}

// SharedWith retorna la lista de usuarios con quienes se compartió
func (n *Note) SharedWith() []SharedWith {
	return n.shared
}

// CreatedAt retorna la fecha de creación
func (n *Note) CreatedAt() time.Time {
	return n.createdAt
}

// UpdatedAt retorna la fecha de última actualización
func (n *Note) UpdatedAt() time.Time {
	return n.updatedAt
}

// IsOwner verifica si un usuario es el propietario
func (n *Note) IsOwner(userID uuid.UUID) bool {
	return n.ownerID == userID
}

// IsDeleted verifica si la nota ha sido eliminada
func (n *Note) IsDeleted() bool {
	return n.deletedAt != nil
}

// UpdateTitle actualiza el título
func (n *Note) UpdateTitle(newTitle string) error {
	if err := validateTitle(newTitle); err != nil {
		return err
	}

	n.title = newTitle
	n.updatedAt = time.Now().UTC()
	return nil
}

// UpdateContent actualiza el contenido
func (n *Note) UpdateContent(newContent string) error {
	if err := validateContent(newContent); err != nil {
		return err
	}

	n.content = newContent
	n.updatedAt = time.Now().UTC()
	return nil
}

// UpdateTags actualiza las etiquetas
func (n *Note) UpdateTags(newTags []string) error {
	if err := validateTags(newTags); err != nil {
		return err
	}

	n.tags = newTags
	n.updatedAt = time.Now().UTC()
	return nil
}

// ShareWith comparte la nota con otro usuario
func (n *Note) ShareWith(userID uuid.UUID, permission valueobject.Permission) error {
	// Validar que no sea el propietario
	if n.IsOwner(userID) {
		return apperrors.NewValidationError("cannot share note with owner")
	}

	// Verificar si ya está compartida
	for i, s := range n.shared {
		if s.UserID == userID {
			// Actualizar permiso
			n.shared[i].Permission = permission
			n.shared[i].SharedAt = time.Now().UTC()
			n.updatedAt = time.Now().UTC()
			return nil
		}
	}

	// Agregar nuevo acceso compartido
	n.shared = append(n.shared, SharedWith{
		UserID:     userID,
		Permission: permission,
		SharedAt:   time.Now().UTC(),
	})

	n.updatedAt = time.Now().UTC()
	return nil
}

// UnshareWith revoca acceso a otro usuario
func (n *Note) UnshareWith(userID uuid.UUID) error {
	for i, s := range n.shared {
		if s.UserID == userID {
			// Remover del slice
			n.shared = append(n.shared[:i], n.shared[i+1:]...)
			n.updatedAt = time.Now().UTC()
			return nil
		}
	}

	return apperrors.NewNotFoundError("shared access")
}

// HasPermission verifica si un usuario tiene un permiso específico
func (n *Note) HasPermission(userID uuid.UUID, requiredPermission valueobject.Permission) bool {
	// El propietario siempre tiene manage
	if n.IsOwner(userID) {
		return true
	}

	// Buscar en usuarios compartidos
	for _, s := range n.shared {
		if s.UserID == userID {
			return s.Permission.IsSupersetOf(requiredPermission)
		}
	}

	return false
}

// GetPermission obtiene el permiso de un usuario
func (n *Note) GetPermission(userID uuid.UUID) (valueobject.Permission, error) {
	// El propietario siempre tiene manage
	if n.IsOwner(userID) {
		return valueobject.PermissionManage, nil
	}

	// Buscar en usuarios compartidos
	for _, s := range n.shared {
		if s.UserID == userID {
			return s.Permission, nil
		}
	}

	return "", apperrors.NewForbiddenError("no access to this note")
}

// SoftDelete marca la nota como eliminada
func (n *Note) SoftDelete() {
	now := time.Now().UTC()
	n.deletedAt = &now
	n.updatedAt = now
}

// Validaciones
func validateTitle(title string) error {
	if title == "" {
		return apperrors.NewValidationError("title cannot be empty")
	}

	if len(title) > 200 {
		return apperrors.NewValidationError("title must not exceed 200 characters")
	}

	return nil
}

func validateContent(content string) error {
	if content == "" {
		return apperrors.NewValidationError("content cannot be empty")
	}

	if len(content) > 50000 {
		return apperrors.NewValidationError("content must not exceed 50000 characters")
	}

	return nil
}

func validateTags(tags []string) error {
	if len(tags) > 20 {
		return apperrors.NewValidationError("maximum 20 tags allowed")
	}

	for _, tag := range tags {
		if tag == "" {
			return apperrors.NewValidationError("tags cannot be empty")
		}

		if len(tag) > 50 {
			return apperrors.NewValidationError("tag must not exceed 50 characters")
		}
	}

	return nil
}

🎓 Explicación:

  • SharedWith: Struct anidado para representar acceso compartido
  • ShareWith(): Lógica de negocio para compartir notas
  • HasPermission(): Verifica acceso granular
  • GetPermission(): Obtiene el permiso exacto de un usuario
  • Validaciones encapsuladas: No se pueden crear notas inválidas

🔌 Fase 2: Contratos (Ports) - Definición de Interfaces

Los puertos son interfaces que definen contratos entre capas. El dominio define qué necesita, y los adaptadores lo implementan.

Paso 1: Repository Ports (Interfaces de Persistencia)

nvim internal/domain/repository/user_repository.go

Contenido:

package repository

import (
	"context"

	"github.com/google/uuid"

	"github.com/tuusuario/notes-api/internal/domain/entity"
	"github.com/tuusuario/notes-api/internal/domain/valueobject"
)

// UserRepository define las operaciones que puede hacer cualquier persistencia de usuarios
type UserRepository interface {
	// Create guarda un nuevo usuario
	Create(ctx context.Context, user *entity.User) error

	// FindByID obtiene un usuario por ID
	FindByID(ctx context.Context, id uuid.UUID) (*entity.User, error)

	// FindByEmail obtiene un usuario por email
	FindByEmail(ctx context.Context, email valueobject.Email) (*entity.User, error)

	// Update actualiza un usuario existente
	Update(ctx context.Context, user *entity.User) error

	// Delete elimina un usuario (soft delete)
	Delete(ctx context.Context, id uuid.UUID) error

	// ExistsByEmail verifica si un email ya existe
	ExistsByEmail(ctx context.Context, email valueobject.Email) (bool, error)

	// List obtiene una lista paginada de usuarios
	List(ctx context.Context, limit, offset int) ([]*entity.User, error)

	// Count retorna el número total de usuarios
	Count(ctx context.Context) (int, error)
}

🎓 Explicación:

  • Context: Cada método recibe context.Context para cancelación y timeouts
  • Sin implementación: Solo define qué debe hacer
  • Agnóstico: No menciona PostgreSQL, MongoDB, etc.
  • Operaciones básicas: CRUD + búsquedas comunes
nvim internal/domain/repository/note_repository.go

Contenido:

package repository

import (
	"context"

	"github.com/google/uuid"

	"github.com/tuusuario/notes-api/internal/domain/entity"
)

// NoteRepository define las operaciones que puede hacer cualquier persistencia de notas
type NoteRepository interface {
	// Create guarda una nueva nota
	Create(ctx context.Context, note *entity.Note) error

	// FindByID obtiene una nota por ID
	FindByID(ctx context.Context, id uuid.UUID) (*entity.Note, error)

	// FindByOwner obtiene todas las notas de un propietario
	FindByOwner(ctx context.Context, ownerID uuid.UUID) ([]*entity.Note, error)

	// FindByOwnerPaginated obtiene notas de un propietario con paginación
	FindByOwnerPaginated(
		ctx context.Context,
		ownerID uuid.UUID,
		limit int,
		offset int,
	) ([]*entity.Note, error)

	// FindSharedWith obtiene notas compartidas con un usuario
	FindSharedWith(ctx context.Context, userID uuid.UUID) ([]*entity.Note, error)

	// Update actualiza una nota existente
	Update(ctx context.Context, note *entity.Note) error

	// Delete elimina una nota (soft delete)
	Delete(ctx context.Context, id uuid.UUID) error

	// CountByOwner cuenta las notas de un propietario
	CountByOwner(ctx context.Context, ownerID uuid.UUID) (int, error)

	// SearchByTitle busca notas por título
	SearchByTitle(
		ctx context.Context,
		userID uuid.UUID,
		title string,
	) ([]*entity.Note, error)

	// SearchByTag busca notas por etiqueta
	SearchByTag(
		ctx context.Context,
		userID uuid.UUID,
		tag string,
	) ([]*entity.Note, error)
}

💡 Observación: Los puertos definen qué operaciones necesita el negocio, no cómo implementarlas.


🎬 Fase 3: Persistencia (Adaptadores Secundarios)

Ahora implementamos cómo se guardan datos en las bases de datos reales.

Paso 1: Configuración de Bases de Datos

nvim internal/infrastructure/database/postgres.go

Contenido:

package database

import (
	"context"
	"database/sql"
	"fmt"
	"time"

	_ "github.com/lib/pq"
)

// PostgreSQL representa la conexión a PostgreSQL
type PostgreSQL struct {
	conn *sql.DB
}

// NewPostgreSQL crea una nueva conexión a PostgreSQL
func NewPostgreSQL(connectionString string) (*PostgreSQL, error) {
	// Abrir conexión
	conn, err := sql.Open("postgres", connectionString)
	if err != nil {
		return nil, fmt.Errorf("failed to open postgres: %w", err)
	}

	// Verificar conexión
	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if err := conn.PingContext(ctx); err != nil {
		return nil, fmt.Errorf("failed to ping postgres: %w", err)
	}

	// Configurar pool de conexiones
	conn.SetMaxOpenConns(25)
	conn.SetMaxIdleConns(5)
	conn.SetConnMaxLifetime(5 * time.Minute)
	conn.SetConnMaxIdleTime(10 * time.Minute)

	return &PostgreSQL{conn: conn}, nil
}

// Connection retorna la conexión actual
func (p *PostgreSQL) Connection() *sql.DB {
	return p.conn
}

// Close cierra la conexión
func (p *PostgreSQL) Close() error {
	if p.conn == nil {
		return nil
	}
	return p.conn.Close()
}

// Migrate ejecuta migraciones (las crearemos después)
func (p *PostgreSQL) Migrate() error {
	// Crear tabla de usuarios
	createUsersTable := `
	CREATE TABLE IF NOT EXISTS users (
		id UUID PRIMARY KEY,
		email VARCHAR(254) UNIQUE NOT NULL,
		password_hash VARCHAR(255) NOT NULL,
		name VARCHAR(100) NOT NULL,
		role VARCHAR(50) NOT NULL,
		created_at TIMESTAMP NOT NULL,
		updated_at TIMESTAMP NOT NULL,
		deleted_at TIMESTAMP
	);

	CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
	CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at);
	`

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	_, err := p.conn.ExecContext(ctx, createUsersTable)
	if err != nil {
		return fmt.Errorf("failed to create users table: %w", err)
	}

	return nil
}

🎓 Explicación:

  • Connection Pooling: Configura tamaño de pool para optimo performance
  • Health Check: Ping() verifica que la conexión funciona
  • Migrate(): Crea tablas e índices al iniciar
nvim internal/infrastructure/database/mongodb.go

Contenido:

package database

import (
	"context"
	"fmt"
	"time"

	"go.mongodb.org/mongo-driver/mongo"
	"go.mongodb.org/mongo-driver/mongo/options"
)

// MongoDB representa la conexión a MongoDB
type MongoDB struct {
	client   *mongo.Client
	database *mongo.Database
}

// NewMongoDB crea una nueva conexión a MongoDB
func NewMongoDB(uri string, dbName string) (*MongoDB, error) {
	// Crear contexto con timeout
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	// Conectar
	client, err := mongo.Connect(ctx, options.Client().ApplyURI(uri))
	if err != nil {
		return nil, fmt.Errorf("failed to connect to mongodb: %w", err)
	}

	// Verificar conexión
	ctx, cancel = context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if err := client.Ping(ctx, nil); err != nil {
		return nil, fmt.Errorf("failed to ping mongodb: %w", err)
	}

	database := client.Database(dbName)

	return &MongoDB{
		client:   client,
		database: database,
	}, nil
}

// Database retorna la base de datos
func (m *MongoDB) Database() *mongo.Database {
	return m.database
}

// Collection retorna una colección
func (m *MongoDB) Collection(name string) *mongo.Collection {
	return m.database.Collection(name)
}

// Close cierra la conexión
func (m *MongoDB) Close() error {
	if m.client == nil {
		return nil
	}

	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	return m.client.Disconnect(ctx)
}

// CreateIndexes crea índices en MongoDB
func (m *MongoDB) CreateIndexes() error {
	// Los índices los crearemos en los repositorios específicos
	return nil
}

💡 Tip: MongoDB usa Disconnect() en lugar de Close().


🏗️ Fase 3 (Continuación): Implementación de Repositorios

Paso 2: PostgreSQL User Repository

nvim internal/adapter/repository/postgres/user_repository.go

Contenido completo:

package postgres

import (
	"context"
	"database/sql"
	"errors"
	"fmt"

	"github.com/google/uuid"

	"github.com/tuusuario/notes-api/internal/domain/entity"
	"github.com/tuusuario/notes-api/internal/domain/repository"
	"github.com/tuusuario/notes-api/internal/domain/valueobject"
	apperrors "github.com/tuusuario/notes-api/pkg/errors"
)

// UserRepository implementa repository.UserRepository usando PostgreSQL
type UserRepository struct {
	db *sql.DB
}

// NewUserRepository crea un nuevo PostgreSQL UserRepository
func NewUserRepository(db *sql.DB) repository.UserRepository {
	return &UserRepository{db: db}
}

// Create guarda un nuevo usuario
func (r *UserRepository) Create(ctx context.Context, user *entity.User) error {
	const query = `
	INSERT INTO users (id, email, password_hash, name, role, created_at, updated_at)
	VALUES ($1, $2, $3, $4, $5, $6, $7)
	`

	_, err := r.db.ExecContext(
		ctx,
		query,
		user.ID().String(),
		user.Email().String(),
		user.Password().Hash(),
		user.Name(),
		user.Role().String(),
		user.CreatedAt(),
		user.UpdatedAt(),
	)

	if err != nil {
		// Verificar si es error de email duplicado
		if err.Error() == "pq: duplicate key value violates unique constraint \"users_email_key\"" {
			return apperrors.NewConflictError("email already exists")
		}
		return apperrors.NewInternalError("failed to create user", err)
	}

	return nil
}

// FindByID obtiene un usuario por ID
func (r *UserRepository) FindByID(ctx context.Context, id uuid.UUID) (*entity.User, error) {
	const query = `
	SELECT id, email, password_hash, name, role, created_at, updated_at, deleted_at
	FROM users
	WHERE id = $1 AND deleted_at IS NULL
	`

	var (
		userID    string
		email     string
		passHash  string
		name      string
		role      string
		createdAt interface{}
		updatedAt interface{}
		deletedAt *interface{}
	)

	err := r.db.QueryRowContext(ctx, query, id.String()).Scan(
		&userID,
		&email,
		&passHash,
		&name,
		&role,
		&createdAt,
		&updatedAt,
		&deletedAt,
	)

	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, apperrors.NewNotFoundError("user")
		}
		return nil, apperrors.NewInternalError("failed to find user", err)
	}

	// Reconstruir value objects
	emailVO, _ := valueobject.NewEmail(email)
	passwordVO := valueobject.NewPasswordFromHash(passHash)
	roleVO, _ := valueobject.NewRole(role)

	// Crear usuario desde datos
	reconstructedUser, _ := entity.NewUser(emailVO, passwordVO, name, roleVO)

	return reconstructedUser, nil
}

// FindByEmail obtiene un usuario por email
func (r *UserRepository) FindByEmail(ctx context.Context, email valueobject.Email) (*entity.User, error) {
	const query = `
	SELECT id, email, password_hash, name, role, created_at, updated_at, deleted_at
	FROM users
	WHERE email = $1 AND deleted_at IS NULL
	`

	var (
		userID    string
		emailStr  string
		passHash  string
		nameStr   string
		roleStr   string
		createdAt interface{}
		updatedAt interface{}
		deletedAt *interface{}
	)

	err := r.db.QueryRowContext(ctx, query, email.String()).Scan(
		&userID,
		&emailStr,
		&passHash,
		&nameStr,
		&roleStr,
		&createdAt,
		&updatedAt,
		&deletedAt,
	)

	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			return nil, apperrors.NewNotFoundError("user")
		}
		return nil, apperrors.NewInternalError("failed to find user by email", err)
	}

	// Reconstruir value objects
	emailVO, _ := valueobject.NewEmail(emailStr)
	passwordVO := valueobject.NewPasswordFromHash(passHash)
	roleVO, _ := valueobject.NewRole(roleStr)

	// Crear usuario
	user, _ := entity.NewUser(emailVO, passwordVO, nameStr, roleVO)

	return user, nil
}

// Update actualiza un usuario
func (r *UserRepository) Update(ctx context.Context, user *entity.User) error {
	const query = `
	UPDATE users
	SET email = $1, password_hash = $2, name = $3, role = $4, updated_at = $5
	WHERE id = $6 AND deleted_at IS NULL
	`

	result, err := r.db.ExecContext(
		ctx,
		query,
		user.Email().String(),
		user.Password().Hash(),
		user.Name(),
		user.Role().String(),
		user.UpdatedAt(),
		user.ID().String(),
	)

	if err != nil {
		return apperrors.NewInternalError("failed to update user", err)
	}

	rowsAffected, err := result.RowsAffected()
	if err != nil {
		return apperrors.NewInternalError("failed to check rows affected", err)
	}

	if rowsAffected == 0 {
		return apperrors.NewNotFoundError("user")
	}

	return nil
}

// Delete elimina un usuario (soft delete)
func (r *UserRepository) Delete(ctx context.Context, id uuid.UUID) error {
	const query = `
	UPDATE users
	SET deleted_at = NOW()
	WHERE id = $1
	`

	result, err := r.db.ExecContext(ctx, query, id.String())
	if err != nil {
		return apperrors.NewInternalError("failed to delete user", err)
	}

	rowsAffected, err := result.RowsAffected()
	if err != nil {
		return apperrors.NewInternalError("failed to check rows affected", err)
	}

	if rowsAffected == 0 {
		return apperrors.NewNotFoundError("user")
	}

	return nil
}

// ExistsByEmail verifica si un email existe
func (r *UserRepository) ExistsByEmail(ctx context.Context, email valueobject.Email) (bool, error) {
	const query = `
	SELECT EXISTS(
		SELECT 1 FROM users
		WHERE email = $1 AND deleted_at IS NULL
	)
	`

	var exists bool
	err := r.db.QueryRowContext(ctx, query, email.String()).Scan(&exists)
	if err != nil {
		return false, apperrors.NewInternalError("failed to check email existence", err)
	}

	return exists, nil
}

// List obtiene usuarios con paginación
func (r *UserRepository) List(ctx context.Context, limit, offset int) ([]*entity.User, error) {
	const query = `
	SELECT id, email, password_hash, name, role, created_at, updated_at
	FROM users
	WHERE deleted_at IS NULL
	ORDER BY created_at DESC
	LIMIT $1 OFFSET $2
	`

	rows, err := r.db.QueryContext(ctx, query, limit, offset)
	if err != nil {
		return nil, apperrors.NewInternalError("failed to list users", err)
	}
	defer rows.Close()

	users := []*entity.User{}

	for rows.Next() {
		var (
			userID    string
			email     string
			passHash  string
			name      string
			role      string
			createdAt interface{}
			updatedAt interface{}
		)

		if err := rows.Scan(&userID, &email, &passHash, &name, &role, &createdAt, &updatedAt); err != nil {
			return nil, apperrors.NewInternalError("failed to scan user", err)
		}

		emailVO, _ := valueobject.NewEmail(email)
		passwordVO := valueobject.NewPasswordFromHash(passHash)
		roleVO, _ := valueobject.NewRole(role)

		user, _ := entity.NewUser(emailVO, passwordVO, name, roleVO)
		users = append(users, user)
	}

	if err := rows.Err(); err != nil {
		return nil, apperrors.NewInternalError("rows error", err)
	}

	return users, nil
}

// Count cuenta el total de usuarios
func (r *UserRepository) Count(ctx context.Context) (int, error) {
	const query = `SELECT COUNT(*) FROM users WHERE deleted_at IS NULL`

	var count int
	err := r.db.QueryRowContext(ctx, query).Scan(&count)
	if err != nil {
		return 0, apperrors.NewInternalError("failed to count users", err)
	}

	return count, nil
}

🎓 Explicación:

  • Queries parameterizadas: Protege contra SQL injection con $1, $2, etc.
  • Manejo de errores: Convierte errores SQL en AppError específicos
  • Soft delete: Filtra deleted_at IS NULL en selects
  • Reconstrucción de entidades: Recrea value objects desde datos de BD
  • ExistsByEmail(): Usado en el use case de Register para verificar duplicados

Paso 3: MongoDB Note Repository

nvim internal/adapter/repository/mongo/note_repository.go

Contenido completo:

package mongo

import (
	"context"
	"fmt"
	"time"

	"github.com/google/uuid"
	"go.mongodb.org/mongo-driver/bson"
	"go.mongodb.org/mongo-driver/mongo"
	"go.mongodb.org/mongo-driver/mongo/options"

	"github.com/tuusuario/notes-api/internal/domain/entity"
	"github.com/tuusuario/notes-api/internal/domain/repository"
	"github.com/tuusuario/notes-api/internal/domain/valueobject"
	apperrors "github.com/tuusuario/notes-api/pkg/errors"
)

// noteDocument es el modelo de MongoDB para notas
type noteDocument struct {
	ID        string            `bson:"_id"`
	OwnerID   string            `bson:"owner_id"`
	Title     string            `bson:"title"`
	Content   string            `bson:"content"`
	Tags      []string          `bson:"tags"`
	Shared    []sharedDocument  `bson:"shared"`
	CreatedAt time.Time         `bson:"created_at"`
	UpdatedAt time.Time         `bson:"updated_at"`
	DeletedAt *time.Time        `bson:"deleted_at,omitempty"`
}

// sharedDocument representa un usuario con quien se compartió la nota
type sharedDocument struct {
	UserID     string    `bson:"user_id"`
	Permission string    `bson:"permission"`
	SharedAt   time.Time `bson:"shared_at"`
}

// NoteRepository implementa repository.NoteRepository usando MongoDB
type NoteRepository struct {
	collection *mongo.Collection
}

// NewNoteRepository crea un nuevo MongoDB NoteRepository
func NewNoteRepository(database *mongo.Database) repository.NoteRepository {
	collection := database.Collection("notes")

	// Crear índices
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	indexes := []mongo.IndexModel{
		{
			Keys: bson.D{{Key: "owner_id", Value: 1}},
		},
		{
			Keys: bson.D{{Key: "shared.user_id", Value: 1}},
		},
		{
			Keys: bson.D{{Key: "tags", Value: 1}},
		},
		{
			Keys: bson.D{
				{Key: "title", Value: "text"},
				{Key: "content", Value: "text"},
			},
		},
		{
			Keys: bson.D{{Key: "created_at", Value: -1}},
		},
	}

	_, err := collection.Indexes().CreateMany(ctx, indexes)
	if err != nil {
		fmt.Printf("Warning: error creating indexes: %v\n", err)
	}

	return &NoteRepository{collection: collection}
}

// Create guarda una nueva nota
func (r *NoteRepository) Create(ctx context.Context, note *entity.Note) error {
	doc := r.toDocument(note)

	_, err := r.collection.InsertOne(ctx, doc)
	if err != nil {
		return apperrors.NewInternalError("failed to create note", err)
	}

	return nil
}

// FindByID obtiene una nota por ID
func (r *NoteRepository) FindByID(ctx context.Context, id uuid.UUID) (*entity.Note, error) {
	filter := bson.M{"_id": id.String(), "deleted_at": bson.M{"$eq": nil}}

	var doc noteDocument
	err := r.collection.FindOne(ctx, filter).Decode(&doc)
	if err != nil {
		if err == mongo.ErrNoDocuments {
			return nil, apperrors.NewNotFoundError("note")
		}
		return nil, apperrors.NewInternalError("failed to find note", err)
	}

	return r.toDomain(&doc), nil
}

// FindByOwner obtiene todas las notas de un propietario
func (r *NoteRepository) FindByOwner(ctx context.Context, ownerID uuid.UUID) ([]*entity.Note, error) {
	filter := bson.M{
		"owner_id":  ownerID.String(),
		"deleted_at": bson.M{"$eq": nil},
	}

	opts := options.Find().SetSort(bson.M{"created_at": -1})

	cursor, err := r.collection.Find(ctx, filter, opts)
	if err != nil {
		return nil, apperrors.NewInternalError("failed to find notes", err)
	}
	defer cursor.Close(ctx)

	var docs []noteDocument
	if err := cursor.All(ctx, &docs); err != nil {
		return nil, apperrors.NewInternalError("failed to decode notes", err)
	}

	result := make([]*entity.Note, len(docs))
	for i, doc := range docs {
		result[i] = r.toDomain(&doc)
	}

	return result, nil
}

// FindByOwnerPaginated obtiene notas del propietario con paginación
func (r *NoteRepository) FindByOwnerPaginated(
	ctx context.Context,
	ownerID uuid.UUID,
	limit int,
	offset int,
) ([]*entity.Note, error) {
	filter := bson.M{
		"owner_id":  ownerID.String(),
		"deleted_at": bson.M{"$eq": nil},
	}

	opts := options.Find().
		SetSort(bson.M{"created_at": -1}).
		SetLimit(int64(limit)).
		SetSkip(int64(offset))

	cursor, err := r.collection.Find(ctx, filter, opts)
	if err != nil {
		return nil, apperrors.NewInternalError("failed to find notes", err)
	}
	defer cursor.Close(ctx)

	var docs []noteDocument
	if err := cursor.All(ctx, &docs); err != nil {
		return nil, apperrors.NewInternalError("failed to decode notes", err)
	}

	result := make([]*entity.Note, len(docs))
	for i, doc := range docs {
		result[i] = r.toDomain(&doc)
	}

	return result, nil
}

// FindSharedWith obtiene notas compartidas con un usuario
func (r *NoteRepository) FindSharedWith(ctx context.Context, userID uuid.UUID) ([]*entity.Note, error) {
	filter := bson.M{
		"shared.user_id": userID.String(),
		"deleted_at":     bson.M{"$eq": nil},
	}

	cursor, err := r.collection.Find(ctx, filter)
	if err != nil {
		return nil, apperrors.NewInternalError("failed to find shared notes", err)
	}
	defer cursor.Close(ctx)

	var docs []noteDocument
	if err := cursor.All(ctx, &docs); err != nil {
		return nil, apperrors.NewInternalError("failed to decode notes", err)
	}

	notes := make([]*entity.Note, len(docs))
	for i, doc := range docs {
		notes[i] = r.toDomain(&doc)
	}

	return notes, nil
}

// Update actualiza una nota
func (r *NoteRepository) Update(ctx context.Context, note *entity.Note) error {
	filter := bson.M{"_id": note.ID().String()}
	update := bson.M{
		"$set": bson.M{
			"title":      note.Title(),
			"content":    note.Content(),
			"tags":       note.Tags(),
			"updated_at": note.UpdatedAt(),
		},
	}

	result, err := r.collection.UpdateOne(ctx, filter, update)
	if err != nil {
		return apperrors.NewInternalError("failed to update note", err)
	}

	if result.MatchedCount == 0 {
		return apperrors.NewNotFoundError("note")
	}

	return nil
}

// Delete elimina una nota (soft delete)
func (r *NoteRepository) Delete(ctx context.Context, id uuid.UUID) error {
	filter := bson.M{"_id": id.String()}
	update := bson.M{
		"$set": bson.M{
			"deleted_at": time.Now().UTC(),
		},
	}

	result, err := r.collection.UpdateOne(ctx, filter, update)
	if err != nil {
		return apperrors.NewInternalError("failed to delete note", err)
	}

	if result.MatchedCount == 0 {
		return apperrors.NewNotFoundError("note")
	}

	return nil
}

// CountByOwner cuenta notas del propietario
func (r *NoteRepository) CountByOwner(ctx context.Context, ownerID uuid.UUID) (int, error) {
	filter := bson.M{
		"owner_id":  ownerID.String(),
		"deleted_at": bson.M{"$eq": nil},
	}

	count, err := r.collection.CountDocuments(ctx, filter)
	if err != nil {
		return 0, apperrors.NewInternalError("failed to count notes", err)
	}

	return int(count), nil
}

// SearchByTitle busca notas por título usando full-text search
func (r *NoteRepository) SearchByTitle(
	ctx context.Context,
	userID uuid.UUID,
	query string,
) ([]*entity.Note, error) {
	filter := bson.M{
		"$text": bson.M{"$search": query},
		"$or": []bson.M{
			{"owner_id": userID.String()},
			{"shared.user_id": userID.String()},
		},
		"deleted_at": bson.M{"$eq": nil},
	}

	cursor, err := r.collection.Find(ctx, filter)
	if err != nil {
		return nil, apperrors.NewInternalError("failed to search notes", err)
	}
	defer cursor.Close(ctx)

	var docs []noteDocument
	if err := cursor.All(ctx, &docs); err != nil {
		return nil, apperrors.NewInternalError("failed to decode notes", err)
	}

	notes := make([]*entity.Note, len(docs))
	for i, doc := range docs {
		notes[i] = r.toDomain(&doc)
	}

	return notes, nil
}

// Métodos privados para conversión

func (r *NoteRepository) toDocument(note *entity.Note) noteDocument {
	shared := make([]sharedDocument, 0)
	// Aquí irían los datos de SharedWith de la entidad

	return noteDocument{
		ID:        note.ID().String(),
		OwnerID:   note.OwnerID().String(),
		Title:     note.Title(),
		Content:   note.Content(),
		Tags:      note.Tags(),
		Shared:    shared,
		CreatedAt: note.CreatedAt(),
		UpdatedAt: note.UpdatedAt(),
	}
}

func (r *NoteRepository) toDomain(doc *noteDocument) *entity.Note {
	ownerID, _ := uuid.Parse(doc.OwnerID)

	note, _ := entity.NewNote(ownerID, doc.Title, doc.Content, doc.Tags)

	return note
}

🎓 Explicación:

  • Text Search: Índice text en title y content para búsquedas complejas
  • Indexación: Múltiples índices para diferentes queries (owner_id, tags, timestamps)
  • Soft Delete: Filtra deleted_at en todas las búsquedas
  • Conversión: Métodos privados para convertir entre documentos y entidades

🎯 Fase 4: Lógica de Negocio (Use Cases)

Los use cases implementan la lógica de aplicación, agnósticos a HTTP, BD, etc.

Paso 1: Register Use Case

nvim internal/usecase/auth/register.go

Contenido:

package auth

import (
	"context"

	"github.com/tuusuario/notes-api/internal/domain/entity"
	"github.com/tuusuario/notes-api/internal/domain/repository"
	"github.com/tuusuario/notes-api/internal/domain/valueobject"
	apperrors "github.com/tuusuario/notes-api/pkg/errors"
)

// RegisterInput es la entrada para el caso de uso Register
type RegisterInput struct {
	Email    string
	Password string
	Name     string
}

// RegisterOutput es la salida del caso de uso
type RegisterOutput struct {
	ID    string
	Email string
	Name  string
	Role  string
}

// RegisterUseCase implementa el registro de usuarios
type RegisterUseCase struct {
	userRepo repository.UserRepository
}

// NewRegisterUseCase crea un nuevo RegisterUseCase
func NewRegisterUseCase(userRepo repository.UserRepository) *RegisterUseCase {
	return &RegisterUseCase{
		userRepo: userRepo,
	}
}

// Execute registra un nuevo usuario
func (uc *RegisterUseCase) Execute(ctx context.Context, input RegisterInput) (*RegisterOutput, error) {
	// Crear email validado (se valida en el Value Object)
	email, err := valueobject.NewEmail(input.Email)
	if err != nil {
		return nil, err
	}

	// Verificar que el email no existe
	exists, err := uc.userRepo.ExistsByEmail(ctx, email)
	if err != nil {
		return nil, err
	}

	if exists {
		return nil, apperrors.NewConflictError("email already registered")
	}

	// Crear password validado (incluye bcrypt)
	password, err := valueobject.NewPassword(input.Password)
	if err != nil {
		return nil, err
	}

	// Crear usuario con rol por defecto (Editor)
	defaultRole, _ := valueobject.NewRole(string(valueobject.RoleEditor))

	user, err := entity.NewUser(email, password, input.Name, defaultRole)
	if err != nil {
		return nil, err
	}

	// Guardar en base de datos
	if err := uc.userRepo.Create(ctx, user); err != nil {
		return nil, err
	}

	return &RegisterOutput{
		ID:    user.ID().String(),
		Email: user.Email().String(),
		Name:  user.Name(),
		Role:  user.Role().String(),
	}, nil
}

🎓 Explicación:

  • Validaciones en Domain: Email y Password se validan en los Value Objects
  • Lógica de negocio: Verificar que email no existe antes de crear
  • Agnóstico: No sabe nada de HTTP o BD (inyección de dependencias)
  • Input/Output: Tipos simples para comunicación

Paso 2: Login Use Case

nvim internal/usecase/auth/login.go

Contenido:

package auth

import (
	"context"

	"github.com/tuusuario/notes-api/internal/domain/repository"
	"github.com/tuusuario/notes-api/internal/domain/valueobject"
	apperrors "github.com/tuusuario/notes-api/pkg/errors"
)

// LoginInput es la entrada para el caso de uso Login
type LoginInput struct {
	Email    string
	Password string
}

// LoginOutput es la salida del caso de uso
type LoginOutput struct {
	ID    string
	Email string
	Name  string
	Role  string
}

// LoginUseCase implementa el login de usuarios
type LoginUseCase struct {
	userRepo repository.UserRepository
}

// NewLoginUseCase crea un nuevo LoginUseCase
func NewLoginUseCase(userRepo repository.UserRepository) *LoginUseCase {
	return &LoginUseCase{
		userRepo: userRepo,
	}
}

// Execute realiza el login
func (uc *LoginUseCase) Execute(ctx context.Context, input LoginInput) (*LoginOutput, error) {
	// Crear email validado
	email, err := valueobject.NewEmail(input.Email)
	if err != nil {
		return nil, apperrors.NewUnauthorizedError("invalid credentials")
	}

	// Buscar usuario por email
	user, err := uc.userRepo.FindByEmail(ctx, email)
	if err != nil {
		if apperrors.IsNotFoundError(err) {
			return nil, apperrors.NewUnauthorizedError("invalid credentials")
		}
		return nil, err
	}

	// Verificar contraseña con comparación segura
	if err := user.Password().Compare(input.Password); err != nil {
		return nil, apperrors.NewUnauthorizedError("invalid credentials")
	}

	return &LoginOutput{
		ID:    user.ID().String(),
		Email: user.Email().String(),
		Name:  user.Name(),
		Role:  user.Role().String(),
	}, nil
}

💡 Nota de Seguridad: El mensaje de error es genérico “invalid credentials” para no revelar si el email existe o si la contraseña es incorrecta (timing attack protection).

Paso 3: Create Note Use Case

nvim internal/usecase/note/create_note.go

Contenido:

package note

import (
	"context"

	"github.com/google/uuid"

	"github.com/tuusuario/notes-api/internal/domain/entity"
	"github.com/tuusuario/notes-api/internal/domain/repository"
)

// CreateNoteInput es la entrada
type CreateNoteInput struct {
	UserID  uuid.UUID
	Title   string
	Content string
	Tags    []string
}

// CreateNoteOutput es la salida
type CreateNoteOutput struct {
	ID        string
	Title     string
	Content   string
	Tags      []string
	CreatedAt interface{}
}

// CreateNoteUseCase implementa la creación de notas
type CreateNoteUseCase struct {
	noteRepo repository.NoteRepository
}

// NewCreateNoteUseCase crea un nuevo CreateNoteUseCase
func NewCreateNoteUseCase(noteRepo repository.NoteRepository) *CreateNoteUseCase {
	return &CreateNoteUseCase{
		noteRepo: noteRepo,
	}
}

// Execute crea una nueva nota
func (uc *CreateNoteUseCase) Execute(ctx context.Context, input CreateNoteInput) (*CreateNoteOutput, error) {
	// Crear nota (validaciones incluidas en la entidad)
	note, err := entity.NewNote(
		input.UserID,
		input.Title,
		input.Content,
		input.Tags,
	)
	if err != nil {
		return nil, err
	}

	// Guardar en BD
	if err := uc.noteRepo.Create(ctx, note); err != nil {
		return nil, err
	}

	return &CreateNoteOutput{
		ID:        note.ID().String(),
		Title:     note.Title(),
		Content:   note.Content(),
		Tags:      note.Tags(),
		CreatedAt: note.CreatedAt(),
	}, nil
}

Paso 4: Share Note Use Case (Lógica Compleja con Autorización)

nvim internal/usecase/note/share_note.go

Contenido:

package note

import (
	"context"

	"github.com/google/uuid"

	"github.com/tuusuario/notes-api/internal/domain/repository"
	"github.com/tuusuario/notes-api/internal/domain/valueobject"
	apperrors "github.com/tuusuario/notes-api/pkg/errors"
)

// ShareNoteInput es la entrada
type ShareNoteInput struct {
	NoteID     uuid.UUID
	UserID     uuid.UUID      // Usuario que intenta compartir
	ShareWith  uuid.UUID      // Usuario con quien se quiere compartir
	Permission string         // "View", "Edit", "Manage"
}

// ShareNoteUseCase implementa la lógica de compartir notas
type ShareNoteUseCase struct {
	noteRepo repository.NoteRepository
	userRepo repository.UserRepository
}

// NewShareNoteUseCase crea un nuevo ShareNoteUseCase
func NewShareNoteUseCase(
	noteRepo repository.NoteRepository,
	userRepo repository.UserRepository,
) *ShareNoteUseCase {
	return &ShareNoteUseCase{
		noteRepo: noteRepo,
		userRepo: userRepo,
	}
}

// Execute comparte una nota con otro usuario
func (uc *ShareNoteUseCase) Execute(ctx context.Context, input ShareNoteInput) error {
	// Obtener la nota
	note, err := uc.noteRepo.FindByID(ctx, input.NoteID)
	if err != nil {
		return err
	}

	// Verificar que el usuario que intenta compartir es el propietario
	if !note.IsOwner(input.UserID) {
		return apperrors.NewForbiddenError("only note owner can share")
	}

	// Verificar que el usuario con quien se quiere compartir existe
	_, err = uc.userRepo.FindByID(ctx, input.ShareWith)
	if err != nil {
		if apperrors.IsNotFoundError(err) {
			return apperrors.NewValidationError("user to share with not found")
		}
		return err
	}

	// Crear permiso validado
	permission, err := valueobject.NewPermission(input.Permission)
	if err != nil {
		return err
	}

	// Compartir nota (lógica de dominio validada)
	if err := note.ShareWith(input.ShareWith, permission); err != nil {
		return err
	}

	// Guardar cambios
	if err := uc.noteRepo.Update(ctx, note); err != nil {
		return err
	}

	return nil
}

🎓 Explicación:

  • Autorización: Verifica que solo el propietario pueda compartir (IsOwner)
  • Validación de existencia: Verifica que el usuario destino existe
  • Value Objects: Permission se valida en el Value Object
  • Dominio: La lógica de ShareWith() está en la entidad
  • Atomicidad: Validar todo antes de guardar cambios

🎓 Explicación:

  • Autorización: Verifica que solo el propietario pueda compartir (IsOwner)
  • Validación de existencia: Verifica que el usuario destino existe
  • Value Objects: Permission se valida en el Value Object
  • Dominio: La lógica de ShareWith() está en la entidad
  • Atomicidad: Validar todo antes de guardar cambios

📡 Fase 5: HTTP Handlers y Respuestas

Paso 1: Response Helper System

nvim pkg/response/response.go

Contenido:

package response

import (
	"encoding/json"
	"errors"
	"net/http"

	apperrors "github.com/tuusuario/notes-api/pkg/errors"
)

// SuccessResponse es una respuesta exitosa
type SuccessResponse struct {
	Success bool        `json:"success"`
	Data    interface{} `json:"data"`
	Message string      `json:"message,omitempty"`
}

// ErrorResponse es una respuesta de error
type ErrorResponse struct {
	Success bool   `json:"success"`
	Error   Error  `json:"error"`
}

// Error contiene información del error
type Error struct {
	Type    string `json:"type"`
	Message string `json:"message"`
}

// Success escribe una respuesta exitosa
func Success(w http.ResponseWriter, statusCode int, data interface{}, message string) {
	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(statusCode)

	response := SuccessResponse{
		Success: true,
		Data:    data,
		Message: message,
	}

	json.NewEncoder(w).Encode(response)
}

// ErrorWithCode escribe una respuesta de error con status code apropiado
func ErrorWithCode(w http.ResponseWriter, err error) {
	w.Header().Set("Content-Type", "application/json")

	var appErr *apperrors.AppError
	var statusCode int
	var errorType string

	// Determinar tipo de error y status code
	if ok := errors.As(err, &appErr); !ok {
		// Error desconocido
		statusCode = http.StatusInternalServerError
		errorType = "INTERNAL_ERROR"
	} else {
		switch appErr.Type {
		case apperrors.ErrorTypeValidation:
			statusCode = http.StatusBadRequest
			errorType = "VALIDATION_ERROR"
		case apperrors.ErrorTypeNotFound:
			statusCode = http.StatusNotFound
			errorType = "NOT_FOUND"
		case apperrors.ErrorTypeUnauthorized:
			statusCode = http.StatusUnauthorized
			errorType = "UNAUTHORIZED"
		case apperrors.ErrorTypeForbidden:
			statusCode = http.StatusForbidden
			errorType = "FORBIDDEN"
		case apperrors.ErrorTypeConflict:
			statusCode = http.StatusConflict
			errorType = "CONFLICT"
		default:
			statusCode = http.StatusInternalServerError
			errorType = "INTERNAL_ERROR"
		}
	}

	w.WriteHeader(statusCode)

	response := ErrorResponse{
		Success: false,
		Error: Error{
			Type:    errorType,
			Message: err.Error(),
		},
	}

	json.NewEncoder(w).Encode(response)
}

// BadRequest es un helper para errores de validación
func BadRequest(w http.ResponseWriter, message string) {
	ErrorWithCode(w, apperrors.NewValidationError(message))
}

// NotFound es un helper para errores de no encontrado
func NotFound(w http.ResponseWriter, resource string) {
	ErrorWithCode(w, apperrors.NewNotFoundError(resource))
}

// Unauthorized es un helper para errores de autorización
func Unauthorized(w http.ResponseWriter, message string) {
	ErrorWithCode(w, apperrors.NewUnauthorizedError(message))
}

Paso 2: Auth Handler

nvim internal/adapter/handler/http/auth_handler.go

Contenido:

package http

import (
	"encoding/json"
	"net/http"

	"github.com/tuusuario/notes-api/internal/infrastructure/jwt"
	"github.com/tuusuario/notes-api/internal/usecase/auth"
	"github.com/tuusuario/notes-api/pkg/response"
)

// AuthHandler maneja endpoints de autenticación
type AuthHandler struct {
	registerUC *auth.RegisterUseCase
	loginUC    *auth.LoginUseCase
	jwtManager *jwt.Manager
}

// NewAuthHandler crea un nuevo AuthHandler
func NewAuthHandler(
	registerUC *auth.RegisterUseCase,
	loginUC *auth.LoginUseCase,
	jwtManager *jwt.Manager,
) *AuthHandler {
	return &AuthHandler{
		registerUC: registerUC,
		loginUC:    loginUC,
		jwtManager: jwtManager,
	}
}

// RegisterRequest es el JSON request body para register
type RegisterRequest struct {
	Email    string `json:"email"`
	Password string `json:"password"`
	Name     string `json:"name"`
}

// TokenResponse contiene tokens de acceso
type TokenResponse struct {
	AccessToken  string `json:"access_token"`
	RefreshToken string `json:"refresh_token"`
	ExpiresIn    int    `json:"expires_in"`
	User         *auth.RegisterOutput `json:"user"`
}

// Register maneja POST /auth/register
func (h *AuthHandler) Register(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	var req RegisterRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		response.BadRequest(w, "Invalid request body")
		return
	}

	// Ejecutar use case
	output, err := h.registerUC.Execute(r.Context(), auth.RegisterInput{
		Email:    req.Email,
		Password: req.Password,
		Name:     req.Name,
	})
	if err != nil {
		response.ErrorWithCode(w, err)
		return
	}

	// Generar tokens
	accessToken, err := h.jwtManager.GenerateAccessToken(output.ID, output.Email, output.Role)
	if err != nil {
		response.ErrorWithCode(w, err)
		return
	}

	refreshToken, err := h.jwtManager.GenerateRefreshToken(output.ID, output.Email, output.Role)
	if err != nil {
		response.ErrorWithCode(w, err)
		return
	}

	tokenResp := TokenResponse{
		AccessToken:  accessToken,
		RefreshToken: refreshToken,
		ExpiresIn:    3600, // 1 hora
		User:         output,
	}

	response.Success(w, http.StatusCreated, tokenResp, "User registered successfully")
}

// LoginRequest es el JSON request body para login
type LoginRequest struct {
	Email    string `json:"email"`
	Password string `json:"password"`
}

// Login maneja POST /auth/login
func (h *AuthHandler) Login(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	var req LoginRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		response.BadRequest(w, "Invalid request body")
		return
	}

	// Ejecutar use case
	output, err := h.loginUC.Execute(r.Context(), auth.LoginInput{
		Email:    req.Email,
		Password: req.Password,
	})
	if err != nil {
		response.ErrorWithCode(w, err)
		return
	}

	// Generar tokens
	accessToken, err := h.jwtManager.GenerateAccessToken(output.ID, output.Email, output.Role)
	if err != nil {
		response.ErrorWithCode(w, err)
		return
	}

	refreshToken, err := h.jwtManager.GenerateRefreshToken(output.ID, output.Email, output.Role)
	if err != nil {
		response.ErrorWithCode(w, err)
		return
	}

	tokenResp := TokenResponse{
		AccessToken:  accessToken,
		RefreshToken: refreshToken,
		ExpiresIn:    3600,
		User:         output,
	}

	response.Success(w, http.StatusOK, tokenResp, "Login successful")
}

🎓 Explicación:

  • TokenResponse: Retorna tokens después de autenticación exitosa
  • Generación de tokens: Crea access (corto) y refresh (largo)
  • ExpiresIn: Información del cliente sobre expiración

Paso 3: Notes Handler

nvim internal/adapter/handler/http/notes_handler.go

Contenido:

package http

import (
	"encoding/json"
	"net/http"
	"strconv"

	"github.com/google/uuid"

	"github.com/tuusuario/notes-api/internal/adapter/handler/middleware"
	"github.com/tuusuario/notes-api/internal/usecase/note"
	"github.com/tuusuario/notes-api/pkg/response"
)

// NotesHandler maneja endpoints de notas
type NotesHandler struct {
	createNoteUC *note.CreateNoteUseCase
	shareNoteUC  *note.ShareNoteUseCase
	deleteNoteUC *note.DeleteNoteUseCase
	listNotesUC  *note.ListNotesUseCase
}

// NewNotesHandler crea un nuevo NotesHandler
func NewNotesHandler(
	createNoteUC *note.CreateNoteUseCase,
	shareNoteUC *note.ShareNoteUseCase,
	deleteNoteUC *note.DeleteNoteUseCase,
	listNotesUC *note.ListNotesUseCase,
) *NotesHandler {
	return &NotesHandler{
		createNoteUC: createNoteUC,
		shareNoteUC:  shareNoteUC,
		deleteNoteUC: deleteNoteUC,
		listNotesUC:  listNotesUC,
	}
}

// CreateNoteRequest es el request body para crear nota
type CreateNoteRequest struct {
	Title   string   `json:"title"`
	Content string   `json:"content"`
	Tags    []string `json:"tags,omitempty"`
}

// Create maneja POST /notes
func (h *NotesHandler) Create(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	// Obtener user ID del contexto (middleware lo agregó)
	userID := middleware.UserIDFromContext(r)
	if userID == "" {
		response.Unauthorized(w, "User not authenticated")
		return
	}

	var req CreateNoteRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		response.BadRequest(w, "Invalid request body")
		return
	}

	userUUID, err := uuid.Parse(userID)
	if err != nil {
		response.BadRequest(w, "Invalid user ID")
		return
	}

	// Ejecutar use case
	output, err := h.createNoteUC.Execute(r.Context(), note.CreateNoteInput{
		UserID:  userUUID,
		Title:   req.Title,
		Content: req.Content,
		Tags:    req.Tags,
	})
	if err != nil {
		response.ErrorWithCode(w, err)
		return
	}

	response.Success(w, http.StatusCreated, output, "Note created successfully")
}

// ShareNoteRequest es el request body para compartir nota
type ShareNoteRequest struct {
	ShareWith  string `json:"share_with"`  // UUID del usuario
	Permission string `json:"permission"` // "View", "Edit", "Manage"
}

// Share maneja POST /notes/{id}/share
func (h *NotesHandler) Share(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	// Obtener user ID del contexto
	userID := middleware.UserIDFromContext(r)
	if userID == "" {
		response.Unauthorized(w, "User not authenticated")
		return
	}

	// Extraer noteID de la URL
	noteIDStr := r.URL.Query().Get("id")
	if noteIDStr == "" {
		response.BadRequest(w, "Missing note ID in URL")
		return
	}

	noteID, err := uuid.Parse(noteIDStr)
	if err != nil {
		response.BadRequest(w, "Invalid note ID format")
		return
	}

	var req ShareNoteRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		response.BadRequest(w, "Invalid request body")
		return
	}

	shareWithID, err := uuid.Parse(req.ShareWith)
	if err != nil {
		response.BadRequest(w, "Invalid share_with user ID format")
		return
	}

	userUUID, _ := uuid.Parse(userID)

	// Ejecutar use case
	err = h.shareNoteUC.Execute(r.Context(), note.ShareNoteInput{
		NoteID:     noteID,
		UserID:     userUUID,
		ShareWith:  shareWithID,
		Permission: req.Permission,
	})
	if err != nil {
		response.ErrorWithCode(w, err)
		return
	}

	response.Success(w, http.StatusOK, nil, "Note shared successfully")
}

// Delete maneja DELETE /notes/{id}
func (h *NotesHandler) Delete(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodDelete {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	userID := middleware.UserIDFromContext(r)
	if userID == "" {
		response.Unauthorized(w, "User not authenticated")
		return
	}

	noteIDStr := r.URL.Query().Get("id")
	if noteIDStr == "" {
		response.BadRequest(w, "Missing note ID")
		return
	}

	noteID, err := uuid.Parse(noteIDStr)
	if err != nil {
		response.BadRequest(w, "Invalid note ID format")
		return
	}

	userUUID, _ := uuid.Parse(userID)

	err = h.deleteNoteUC.Execute(r.Context(), note.DeleteNoteInput{
		NoteID: noteID,
		UserID: userUUID,
	})
	if err != nil {
		response.ErrorWithCode(w, err)
		return
	}

	response.Success(w, http.StatusOK, nil, "Note deleted successfully")
}

// List maneja GET /notes
func (h *NotesHandler) List(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodGet {
		http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
		return
	}

	userID := middleware.UserIDFromContext(r)
	if userID == "" {
		response.Unauthorized(w, "User not authenticated")
		return
	}

	// Paginación
	limit := 20
	offset := 0

	if l := r.URL.Query().Get("limit"); l != "" {
		if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
			limit = parsed
		}
	}

	if o := r.URL.Query().Get("offset"); o != "" {
		if parsed, err := strconv.Atoi(o); err == nil && parsed >= 0 {
			offset = parsed
		}
	}

	userUUID, _ := uuid.Parse(userID)

	output, err := h.listNotesUC.Execute(r.Context(), note.ListNotesInput{
		UserID: userUUID,
		Limit:  limit,
		Offset: offset,
	})
	if err != nil {
		response.ErrorWithCode(w, err)
		return
	}

	response.Success(w, http.StatusOK, output, "")
}

🔐 Fase 6: JWT y Middleware

Paso 1: JWT Manager

nvim internal/infrastructure/jwt/jwt.go

Contenido simplificado (el completo está en la rama anterior):

package jwt

import (
	"fmt"
	"time"

	jwtlib "github.com/golang-jwt/jwt/v5"

	"github.com/tuusuario/notes-api/internal/infrastructure/config"
	apperrors "github.com/tuusuario/notes-api/pkg/errors"
)

// Claims son los claims del JWT
type Claims struct {
	UserID string
	Email  string
	Role   string
	jwtlib.RegisteredClaims
}

// Manager maneja generación y validación de JWTs
type Manager struct {
	secret string
	expiry time.Duration
}

// NewManager crea un nuevo Manager
func NewManager(cfg *config.JWTConfig) *Manager {
	return &Manager{
		secret: cfg.Secret,
		expiry: cfg.Expiration,
	}
}

// GenerateAccessToken genera un JWT
func (m *Manager) GenerateAccessToken(userID, email, role string) (string, error) {
	now := time.Now().UTC()
	claims := &Claims{
		UserID: userID,
		Email:  email,
		Role:   role,
		RegisteredClaims: jwtlib.RegisteredClaims{
			ExpiresAt: jwtlib.NewNumericDate(now.Add(m.expiry)),
			IssuedAt:  jwtlib.NewNumericDate(now),
		},
	}

	token := jwtlib.NewWithClaims(jwtlib.SigningMethodHS256, claims)
	tokenString, err := token.SignedString([]byte(m.secret))
	if err != nil {
		return "", apperrors.NewInternalError("failed to sign token", err)
	}

	return tokenString, nil
}

// ValidateToken valida y parsea un JWT
func (m *Manager) ValidateToken(tokenString string) (*Claims, error) {
	token, err := jwtlib.ParseWithClaims(
		tokenString,
		&Claims{},
		func(token *jwtlib.Token) (interface{}, error) {
			if _, ok := token.Method.(*jwtlib.SigningMethodHMAC); !ok {
				return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
			}
			return []byte(m.secret), nil
		},
	)

	if err != nil {
		return nil, apperrors.NewUnauthorizedError("invalid or expired token")
	}

	claims, ok := token.Claims.(*Claims)
	if !ok || !token.Valid {
		return nil, apperrors.NewUnauthorizedError("invalid token claims")
	}

	return claims, nil
}

Paso 2: Auth Middleware

nvim internal/adapter/handler/middleware/auth.go

Contenido:

package middleware

import (
	"context"
	"net/http"
	"strings"

	"github.com/tuusuario/notes-api/internal/infrastructure/jwt"
	"github.com/tuusuario/notes-api/pkg/response"
	apperrors "github.com/tuusuario/notes-api/pkg/errors"
)

// Auth es middleware que valida JWT en Authorization header
func Auth(jwtManager *jwt.Manager) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			authHeader := r.Header.Get("Authorization")
			if authHeader == "" {
				response.ErrorWithCode(w, apperrors.NewUnauthorizedError("missing authorization header"))
				return
			}

			parts := strings.Split(authHeader, " ")
			if len(parts) != 2 || parts[0] != "Bearer" {
				response.ErrorWithCode(w, apperrors.NewUnauthorizedError("invalid authorization header format"))
				return
			}

			tokenString := parts[1]

			claims, err := jwtManager.ValidateToken(tokenString)
			if err != nil {
				response.ErrorWithCode(w, err)
				return
			}

			// Agregar claims al contexto
			ctx := context.WithValue(r.Context(), "user_id", claims.UserID)
			ctx = context.WithValue(ctx, "email", claims.Email)
			ctx = context.WithValue(ctx, "role", claims.Role)

			next.ServeHTTP(w, r.WithContext(ctx))
		})
	}
}

// UserIDFromContext extrae el user ID del contexto
func UserIDFromContext(r *http.Request) string {
	userID, ok := r.Context().Value("user_id").(string)
	if !ok {
		return ""
	}
	return userID
}

// RoleFromContext extrae el role del contexto
func RoleFromContext(r *http.Request) string {
	role, ok := r.Context().Value("role").(string)
	if !ok {
		return ""
	}
	return role
}

🚀 Fase 7: Main.go y Configuración

Paso 1: Main.go Completo

nvim cmd/api/main.go

Contenido:

package main

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/tuusuario/notes-api/internal/adapter/handler/http"
	"github.com/tuusuario/notes-api/internal/adapter/handler/middleware"
	"github.com/tuusuario/notes-api/internal/infrastructure/config"
	"github.com/tuusuario/notes-api/internal/infrastructure/container"
)

func main() {
	// Cargar configuración
	cfg, err := config.LoadFromEnv()
	if err != nil {
		log.Fatalf("Failed to load configuration: %v", err)
	}

	// Inicializar container (inyección de dependencias)
	cont, err := container.New(cfg)
	if err != nil {
		log.Fatalf("Failed to initialize container: %v", err)
	}
	defer cont.Close()

	// Crear router HTTP
	mux := http.NewServeMux()

	// Rutas públicas (sin autenticación)
	mux.HandleFunc("POST /auth/register", cont.AuthHandler.Register)
	mux.HandleFunc("POST /auth/login", cont.AuthHandler.Login)

	// Rutas protegidas (requieren JWT)
	authMiddleware := middleware.Auth(cont.JWTManager)

	mux.Handle("POST /notes", authMiddleware(
		http.HandlerFunc(cont.NotesHandler.Create),
	))
	mux.Handle("GET /notes", authMiddleware(
		http.HandlerFunc(cont.NotesHandler.List),
	))
	mux.Handle("DELETE /notes", authMiddleware(
		http.HandlerFunc(cont.NotesHandler.Delete),
	))
	mux.Handle("POST /notes/share", authMiddleware(
		http.HandlerFunc(cont.NotesHandler.Share),
	))

	// Health check
	mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
		w.Header().Set("Content-Type", "application/json")
		w.WriteHeader(http.StatusOK)
		fmt.Fprint(w, `{"status":"ok","service":"notes-api"}`)
	})

	// Crear servidor HTTP
	server := &http.Server{
		Addr:         fmt.Sprintf(":%d", cfg.Server.Port),
		Handler:      mux,
		ReadTimeout:  15 * time.Second,
		WriteTimeout: 15 * time.Second,
		IdleTimeout:  60 * time.Second,
	}

	// Canal para errores
	errChan := make(chan error, 1)

	// Iniciar servidor en goroutine
	go func() {
		log.Printf("Server starting on port %d", cfg.Server.Port)
		errChan <- server.ListenAndServe()
	}()

	// Graceful shutdown
	signalChan := make(chan os.Signal, 1)
	signal.Notify(signalChan, syscall.SIGINT, syscall.SIGTERM)

	select {
	case <-signalChan:
		log.Println("Shutting down server...")
		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
		defer cancel()

		if err := server.Shutdown(ctx); err != nil {
			log.Fatalf("Server shutdown error: %v", err)
		}
		log.Println("Server shutdown complete")

	case err := <-errChan:
		if err != http.ErrServerClosed {
			log.Fatalf("Server error: %v", err)
		}
	}
}

🎓 Explicación:

  • Container: Inicializa todas las dependencias (DI)
  • Middleware wrapping: Usa authMiddleware(handler) para proteger rutas
  • Graceful shutdown: Cierra correctamente con timeout
  • Error handling: Canales para manejar errores asincrónicamente

🧪 Fase 8: Testing con HTTPie

Paso 1: Crear script de test

cat > scripts/test-api.sh << 'EOF'
#!/bin/bash

# Colores para output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'

BASE_URL="http://localhost:8080"

echo -e "${YELLOW}=== Notes API Test Suite ===${NC}\n"

# Test 1: Health Check
echo -e "${YELLOW}1. Testing Health Check...${NC}"
http GET $BASE_URL/health
echo

# Test 2: Register User
echo -e "${YELLOW}2. Registering user...${NC}"
REGISTER_RESPONSE=$(http POST $BASE_URL/auth/register \
  email=john@example.com \
  password=SecurePass123! \
  name="John Doe")

echo "$REGISTER_RESPONSE" | jq .

# Extraer access token
ACCESS_TOKEN=$(echo "$REGISTER_RESPONSE" | jq -r '.data.access_token')
USER_ID=$(echo "$REGISTER_RESPONSE" | jq -r '.data.user.id')

echo -e "${GREEN}Access Token: ${ACCESS_TOKEN:0:20}...${NC}\n"

# Test 3: Login
echo -e "${YELLOW}3. Testing login...${NC}"
LOGIN_RESPONSE=$(http POST $BASE_URL/auth/login \
  email=john@example.com \
  password=SecurePass123!)

LOGIN_TOKEN=$(echo "$LOGIN_RESPONSE" | jq -r '.data.access_token')
echo -e "${GREEN}Login successful!${NC}\n"

# Test 4: Create Note
echo -e "${YELLOW}4. Creating a note...${NC}"
http POST $BASE_URL/notes \
  Authorization:"Bearer ${LOGIN_TOKEN}" \
  title="Mi Primera Nota" \
  content="Este es el contenido de mi nota" \
  tags:='["importante","trabajo"]'
echo

# Test 5: List Notes
echo -e "${YELLOW}5. Listing notes...${NC}"
http GET $BASE_URL/notes \
  Authorization:"Bearer ${LOGIN_TOKEN}"
echo

echo -e "${GREEN}=== Tests Complete ===${NC}"
EOF

chmod +x scripts/test-api.sh

🎓 Explicación:

  • HTTP requests: HTTPie con sintaxis simple http METHOD URL field:=value
  • Extracción JSON: jq para parsear respuestas
  • Tokens: Reutiliza tokens en requests posteriores
  • Colores: Output legible

Paso 2: Docker Compose

cat > docker-compose.yml << 'EOF'
version: '3.8'

services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: notes_db
      POSTGRES_USER: notes_user
      POSTGRES_PASSWORD: notes_password
    ports:
      - "5432:5432"
    volumes:
      - postgres_data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U notes_user"]
      interval: 10s
      timeout: 5s
      retries: 5

  mongodb:
    image: mongo:7-alpine
    environment:
      MONGO_INITDB_DATABASE: notes_db
    ports:
      - "27017:27017"
    volumes:
      - mongo_data:/data/db
    healthcheck:
      test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  postgres_data:
  mongo_data:
EOF

Paso 3: Makefile

cat > Makefile << 'EOF'
.PHONY: help build run test clean docker-up docker-down

help:
	@echo "Gestor de Notas - Hexagonal Architecture"
	@echo "Available commands:"
	@echo "  make build       - Build the application"
	@echo "  make run         - Run the application"
	@echo "  make test        - Run tests"
	@echo "  make docker-up   - Start Docker containers"
	@echo "  make docker-down - Stop Docker containers"
	@echo "  make clean       - Clean build artifacts"

build:
	@echo "Building application..."
	go build -o bin/api cmd/api/main.go

run: build
	@echo "Running application..."
	./bin/api

test:
	@echo "Running tests..."
	go test -v ./...

docker-up:
	@echo "Starting Docker containers..."
	docker-compose up -d
	@echo "Waiting for services to be ready..."
	sleep 10

docker-down:
	@echo "Stopping Docker containers..."
	docker-compose down

test-api:
	@echo "Running API test suite..."
	bash scripts/test-api.sh

dev: docker-up
	@echo "Development environment ready!"
	@echo "PostgreSQL: localhost:5432"
	@echo "MongoDB: localhost:27017"

clean:
	@echo "Cleaning up..."
	rm -rf bin/
	go clean

.DEFAULT_GOAL := help
EOF

📝 Conclusiones y Próximos Pasos

Hemos construido una Gestor de Notas seguro con Hexagonal Architecture en Go 1.25, implementando:

Arquitectura Hexagonal: Separación clara de domain, ports, adapters ✅ Seguridad: JWT + bcrypt (cost 12) + role-based access ✅ Value Objects: Validación en construcción ✅ Repositorios: PostgreSQL y MongoDB con interfaces ✅ Use Cases: Lógica de negocio agnóstica ✅ Handlers HTTP: Conversión entre JSON y use cases ✅ Testing: HTTPie para validación de endpoints

Próximos Pasos en Producción

  1. Agregar más use cases: Update note, Get note, Search
  2. Implementar logging estruturado: slog de Go 1.21+
  3. Agregar CORS: Permitir requests desde frontend
  4. Rate limiting: Proteger contra abuso
  5. Database migrations: Versionado de schema
  6. CI/CD: GitHub Actions o GitLab CI
  7. Monitoreo: Prometheus metrics
  8. Documentación API: OpenAPI/Swagger

Patrón Hexagonal - Resumen

La arquitectura hexagonal permite:

  • Testabilidad: Mocks de repositorios en tests
  • Mantenibilidad: Cambios en BD sin afectar lógica
  • Escalabilidad: Agregar nuevos adapters fácilmente
  • Independencia: No acoplado a frameworks HTTP o BD

El dominio es el corazón - protégelo con Value Objects y Entidades ricas en lógica. Los adapters son intercambiables - hoy PostgreSQL, mañana otra BD.

¡Felicidades! Has implementado una arquitectura de nivel profesional. Ahora:

  1. Completa el desarrollo con los use cases faltantes
  2. Agrega tests unitarios para cada layer
  3. Implementa logging y monitoreo
  4. Despliega con Docker y orquestación
  5. ¡Comparte tus aprendizajes con la comunidad!

🎓 Recursos Recomendados

  • Domain-Driven Design (Eric Evans) - El libro base
  • Building Microservices (Sam Newman) - Arquitectura a escala
  • Go in Action (William Kennedy) - Go idiomático
  • The Pragmatic Programmer - Mindset profesional

Repositorio de ejemplo: github.com/tu-usuario/notes-api-hexagonal

¡Gracias por seguir esta guía! 🚀

Tags

#golang #hexagonal-architecture #security #jwt #docker #postgresql #mongodb #clean-architecture #backend #go-1.25