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.
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
Escpara entrar en modo normal - Digita:
:e ~/.config/nvim/init.luay 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:
-
Navegación rápida entre archivos:
:Telescope find_files " O presionar <leader>ff -
Búsqueda en el código:
:Telescope live_grep "User" " O presionar <leader>fg -
Crear un archivo nuevo sin salir de Neovim:
:e internal/domain/entity/user.go " Neovim crea el archivo automáticamente -
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.modinicializado - Neovim configurado
- Docker y Docker Compose instalados
- PostgreSQL y MongoDB corriendo en Docker
-
.envcreado - 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()yerrors.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
valuees 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:
deletedAtpermite borrado lógico - Timestamps:
createdAtyupdatedAtpara 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.Contextpara 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
AppErrorespecíficos - Soft delete: Filtra
deleted_at IS NULLen 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_aten 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:
jqpara 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
- Agregar más use cases: Update note, Get note, Search
- Implementar logging estruturado:
slogde Go 1.21+ - Agregar CORS: Permitir requests desde frontend
- Rate limiting: Proteger contra abuso
- Database migrations: Versionado de schema
- CI/CD: GitHub Actions o GitLab CI
- Monitoreo: Prometheus metrics
- 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:
- Completa el desarrollo con los use cases faltantes
- Agrega tests unitarios para cada layer
- Implementa logging y monitoreo
- Despliega con Docker y orquestación
- ¡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
Artículos relacionados
API Versioning Strategies: Cómo Evolucionar APIs sin Romper Clientes
Una guía exhaustiva sobre estrategias de versionado de APIs: URL versioning vs Header versioning, cómo deprecar endpoints sin shock, migration patterns reales, handling de cambios backwards-incompatibles, y decisiones arquitectónicas que importan. Con 50+ ejemplos de código en Go.
Automatizando tu vida con Go CLI: Guía profesional para crear herramientas de línea de comandos escalables
Una guía exhaustiva y paso a paso sobre cómo crear herramientas CLI escalables con Go 1.25.5: desde lo básico hasta proyectos empresariales complejos con flags, configuración, logging, y ejemplos prácticos para Windows y Linux.
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.