Google Forms en Go: De Cero a Producción - Guía Completa de Arquitectura
Una guía exhaustiva, paso a paso, desde cero hasta producción: cómo construir una API RESTful tipo Google Forms con Go 1.25, Clean Architecture, Hexagonal Architecture, DDD, JWT, RBAC, tests. Código real, explicaciones claras, decisiones justificadas.
Google Forms en Go 1.25: De Cero a Producción
Una Guía Exhaustiva, Paso a Paso, 100% Usable y Verificable
Introducción: Qué Vamos a Construir
Este documento es una guía completa de desarrollo donde construiremos un sistema tipo Google Forms desde cero. No es teórico. Es práctico, verificable, y está diseñado para que puedas seguir cada paso y tener un sistema funcional al final.
El Sistema: FormHub (Google Forms en Go)
Construiremos FormHub, un sistema que permite:
✅ Usuarios registrarse, autenticarse, y tener diferentes roles
✅ Administradores crear y gestionar formularios
✅ Editores modificar formularios antes de asignarlos
✅ Visualizadores ver resultados de formularios
✅ Respondedores responder formularios asignados (sin poder modificarlos)
✅ Tokens JWT para autenticación y autorización
✅ RBAC (Control de Acceso Basado en Roles)
✅ Preguntas de múltiples tipos (texto, selección, escala, etc)
✅ Respuestas asociadas a usuarios específicos
✅ Validación en el dominio
✅ Tests en cada capa
Tecnologías Que Usaremos
| Aspecto | Tecnología | Por Qué |
|---|---|---|
| Lenguaje | Go 1.25 | Compilación rápida, binario único, performance |
| BD | PostgreSQL | Relacional, confiable, escalable (agnóstico: puedes cambiar) |
| Autenticación | JWT + Bcrypt | Stateless, seguro, estándar en REST |
| HTTP | net/http | Stdlib de Go, sin dependencias innecesarias |
| Testing | testing nativo + testify | Simple, built-in, assertions claras |
| Validación | go-playground/validator | Tag-based, flexible, profesional |
Arquitectura General
┌─────────────────────────────────────────┐
│ HTTP (net/http) │
│ ↓ Request / ↑ Response │
├─────────────────────────────────────────┤
│ HANDLERS + MIDDLEWARE + ROUTING │
├─────────────────────────────────────────┤
│ USE CASES / APPLICATION SERVICES │
├─────────────────────────────────────────┤
│ DOMAIN (Entities, Value Objects, DDD) │
├─────────────────────────────────────────┤
│ REPOSITORIES (Interfaces/Ports) │
├─────────────────────────────────────────┤
│ ADAPTERS (PostgreSQL, JWT, etc) │
└─────────────────────────────────────────┘
Flujo de una Request:
GET /api/forms/123/responses
↓
[AuthMiddleware] Verifica JWT
↓
[RoleMiddleware] Verifica rol del usuario
↓
Handler: GetFormResponses
↓
UseCase: GetFormResponsesUseCase
↓
Domain: Form.CanViewResponses() ← Lógica de negocio
↓
Repository: GetFormResponses() ← Puerto (interfaz)
↓
PostgresRepository: GetFormResponses() ← Adaptador (implementación)
↓
Response JSON
Cómo Leer Este Documento
Opción 1: Seguir secuencialmente (Recomendado)
- Lee cada sección en orden
- Crea los archivos exactamente como se indica
- Ejecuta los comandos en la terminal
- Verifica que cada paso funciona
Opción 2: Consulta rápida (Si ya conoces arquitectura)
- Salta a la sección que necesites
- Copia el código y adapta a tu caso
Requisitos Previos
# Debes tener instalado:
go version go1.25 o mayor
postgres (servidor corriendo)
git (opcional, para control de versiones)
Si no tienes Go 1.25, descargalo desde https://golang.org/dl
PARTE 1: SETUP DEL PROYECTO
1.1 Crear la Carpeta y Inicializar Go
# 1. Crea la carpeta del proyecto
mkdir formhub
cd formhub
# 2. Inicializa el módulo Go
go mod init github.com/tuusuario/formhub
# Verifica que go.mod se creó
ls -la go.mod
Deberías ver:
module github.com/tuusuario/formhub
go 1.25
¿Por qué? El archivo go.mod es el manifest de tu proyecto. Define:
- El nombre del módulo (ruta de importación)
- La versión de Go que usas
- Las dependencias que necesitas
1.2 Crear la Estructura de Carpetas
Vamos a crear la estructura siguiendo Clean Architecture + Hexagonal Architecture:
# Capa de Dominio (La verdad del negocio)
mkdir -p internal/domain
# Capa de Aplicación (Casos de uso)
mkdir -p internal/usecase
# Capa de Adaptadores
mkdir -p internal/adapter/http/handler
mkdir -p internal/adapter/http/middleware
mkdir -p internal/adapter/repository
mkdir -p internal/adapter/auth
# Configuración e Inicialización
mkdir -p cmd/api
mkdir -p config
# Tests
mkdir -p test
# Documentación y otros
mkdir -p docs
Verifica la estructura:
tree -I 'go.sum' -L 3
Deberías ver algo como:
formhub/
├── cmd/
│ └── api/
├── config/
├── docs/
├── internal/
│ ├── adapter/
│ │ ├── auth/
│ │ ├── http/
│ │ │ ├── handler/
│ │ │ └── middleware/
│ │ └── repository/
│ ├── domain/
│ └── usecase/
├── test/
├── go.mod
└── README.md
¿Por qué esta estructura?
cmd/api: Punto de entrada de la aplicacióninternal/domain: Entities y Value Objects (no exportados, solo para este proyecto)internal/usecase: Casos de uso (servicios)internal/adapter: Todo lo técnico (BD, HTTP, auth)test: Tests separados para mayor claridad
1.3 Crear el Archivo main.go Base
Crea cmd/api/main.go:
package main
import (
"fmt"
"log"
"os"
)
func main() {
// Más adelante agregaremos la lógica aquí
// Por ahora: verificamos que el proyecto compila
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
fmt.Printf("FormHub iniciando en puerto %s\n", port)
log.Printf("Esperando implementación del servidor HTTP...")
}
Prueba que compila:
go run ./cmd/api
# Deberías ver:
# FormHub iniciando en puerto 8080
# Esperando implementación del servidor HTTP...
✅ Checkpoint: Tu proyecto compila correctamente.
1.4 Agregar Dependencias Necesarias
# JWT
go get github.com/golang-jwt/jwt/v5
# Validación
go get github.com/go-playground/validator/v10
# UUID
go get github.com/google/uuid
# PostgreSQL
go get github.com/lib/pq
go get github.com/jmoiron/sqlx
# Bcrypt
go get golang.org/x/crypto
# Testing
go get github.com/stretchr/testify
# Logging
go get github.com/sirupsen/logrus
Verifica que las dependencias se descargaron:
go mod tidy
cat go.mod
Deberías ver todas las dependencias listadas con sus versiones.
¿Por qué cada dependencia?
| Dependencia | Por Qué |
|---|---|
jwt | Tokens de autenticación seguros |
validator | Validación declarativa con tags |
uuid | IDs únicos para entidades |
postgres | Driver para conectarse a PostgreSQL |
sqlx | Mejora sobre database/sql con mejor manejo de structs |
bcrypt | Hashear contraseñas de forma segura |
testify | Assertions y mocks para tests |
logrus | Logging estructurado |
✅ Checkpoint: Todas las dependencias están instaladas.
PARTE 2: DOMINIO (Domain-Driven Design)
Ahora empezamos con la parte más importante: el dominio. Aquí vive la lógica de negocio, completamente independiente de HTTP, BD, JWT, o cualquier otra tecnología.
2.1 Entidades del Dominio: User (Usuario)
Crea internal/domain/user.go:
package domain
import (
"errors"
"time"
)
// Role define el rol que un usuario puede tener en el sistema
// Esto NO es una base de datos, es un tipo de dominio
type Role string
const (
RoleAdmin Role = "admin" // Crear y gestionar formularios
RoleEditor Role = "editor" // Editar formularios
RoleViewer Role = "viewer" // Ver resultados
RoleResponder Role = "responder" // Responder formularios
)
// User es una entidad rica del dominio
// Contiene datos Y lógica de negocio
type User struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
HashedPw string `json:"-"` // Nunca enviamos el hash en JSON
Role Role `json:"role"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// NewUser crea un nuevo usuario
// Esto es una constructor que asegura que el usuario sea válido desde el inicio
func NewUser(id, email, name, hashedPw string, role Role) (*User, error) {
// Validar que los datos sean válidos
if email == "" {
return nil, errors.New("email es requerido")
}
if name == "" {
return nil, errors.New("nombre es requerido")
}
if hashedPw == "" {
return nil, errors.New("contraseña no puede estar vacía")
}
return &User{
ID: id,
Email: email,
Name: name,
HashedPw: hashedPw,
Role: role,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}, nil
}
// CanCreateForm verifica si este usuario puede crear formularios
// Lógica de negocio: solo admins pueden crear
func (u *User) CanCreateForm() bool {
return u.Role == RoleAdmin
}
// CanEditForm verifica si puede editar un formulario
// Lógica: admin o editor
func (u *User) CanEditForm() bool {
return u.Role == RoleAdmin || u.Role == RoleEditor
}
// CanViewResponses verifica si puede ver respuestas
// Lógica: admin o viewer (quien creó el formulario)
func (u *User) CanViewResponses() bool {
return u.Role == RoleAdmin || u.Role == RoleViewer || u.Role == RoleEditor
}
// CanRespondForm verifica si puede responder un formulario
// Lógica: responder o admin (puede responder a cualquiera)
func (u *User) CanRespondForm() bool {
return u.Role == RoleAdmin || u.Role == RoleResponder
}
¿Por qué estructura así?
- Entidad Rica:
Userno es solo datos. Tiene métodos que representan la lógica de negocio. - Validación en Constructor: Aseguramos que un
Usersiempre sea válido. - Métodos de Dominio:
CanCreateForm(),CanEditForm(), etc. encapsulan las reglas de negocio. - Independencia: No sabe nada de HTTP, JWT, o PostgreSQL.
2.2 Entidades del Dominio: Form (Formulario)
Crea internal/domain/form.go:
package domain
import (
"errors"
"time"
)
// FormStatus define el estado de un formulario
type FormStatus string
const (
FormStatusDraft FormStatus = "draft" // En edición
FormStatusPublished FormStatus = "published" // Publicado, no se puede editar
FormStatusClosed FormStatus = "closed" // Cerrado, no acepta respuestas
FormStatusArchived FormStatus = "archived" // Archivado
)
// Question es una pregunta dentro de un formulario
type Question struct {
ID string `json:"id"`
Title string `json:"title"` // "¿Cuál es tu nombre?"
Type string `json:"type"` // "text", "choice", "rating", etc
Required bool `json:"required"` // ¿Es obligatoria?
Options []string `json:"options,omitempty"` // Para preguntas de tipo choice
Order int `json:"order"` // Orden de aparición
}
// Form es un formulario que los usuarios responden
type Form struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
CreatedByID string `json:"created_by_id"` // ID del usuario admin que lo creó
Status FormStatus `json:"status"`
Questions []Question `json:"questions"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
PublishedAt *time.Time `json:"published_at,omitempty"`
ClosedAt *time.Time `json:"closed_at,omitempty"`
}
// NewForm crea un nuevo formulario
func NewForm(id, title, description, createdByID string) (*Form, error) {
if title == "" {
return nil, errors.New("título del formulario es requerido")
}
if createdByID == "" {
return nil, errors.New("debe saber quién crea el formulario")
}
return &Form{
ID: id,
Title: title,
Description: description,
CreatedByID: createdByID,
Status: FormStatusDraft,
Questions: []Question{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}, nil
}
// CanEdit verifica si el formulario puede ser editado
// Lógica: solo en estado draft
func (f *Form) CanEdit() bool {
return f.Status == FormStatusDraft
}
// CanPublish verifica si puede ser publicado
// Lógica: debe estar en draft y tener al menos una pregunta
func (f *Form) CanPublish() bool {
return f.Status == FormStatusDraft && len(f.Questions) > 0
}
// CanReceiveResponse verifica si puede recibir respuestas
// Lógica: debe estar publicado y no cerrado
func (f *Form) CanReceiveResponse() bool {
return f.Status == FormStatusPublished
}
// AddQuestion agrega una pregunta al formulario
func (f *Form) AddQuestion(question Question) error {
if !f.CanEdit() {
return errors.New("no puedes agregar preguntas a un formulario publicado")
}
if question.Title == "" {
return errors.New("la pregunta debe tener un título")
}
question.Order = len(f.Questions) + 1
f.Questions = append(f.Questions, question)
f.UpdatedAt = time.Now()
return nil
}
// Publish publica el formulario
func (f *Form) Publish() error {
if !f.CanPublish() {
return errors.New("no se puede publicar un formulario vacío o ya publicado")
}
now := time.Now()
f.Status = FormStatusPublished
f.PublishedAt = &now
f.UpdatedAt = now
return nil
}
¿Por qué?
- Estados: Un formulario tiene diferentes ciclos de vida (draft → published → closed → archived)
- Lógica Encapsulada:
CanEdit(),CanPublish()usan el estado para tomar decisiones - Validaciones en el Dominio: No confiar en la BD o HTTP para validar
- Questions como Nested: Las preguntas son parte del formulario, con su propio Order
2.3 Entidades del Dominio: FormResponse (Respuesta)
Crea internal/domain/form_response.go:
package domain
import (
"errors"
"time"
)
// Answer es la respuesta de un usuario a una pregunta específica
type Answer struct {
QuestionID string `json:"question_id"`
Value string `json:"value"` // Puede ser texto, número, etc
}
// FormResponse es cuando un usuario responde un formulario completo
type FormResponse struct {
ID string `json:"id"`
FormID string `json:"form_id"`
UserID string `json:"user_id"`
Answers []Answer `json:"answers"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// NewFormResponse crea una nueva respuesta
func NewFormResponse(id, formID, userID string) (*FormResponse, error) {
if formID == "" {
return nil, errors.New("formID es requerido")
}
if userID == "" {
return nil, errors.New("userID es requerido")
}
return &FormResponse{
ID: id,
FormID: formID,
UserID: userID,
Answers: []Answer{},
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}, nil
}
// AddAnswer agrega una respuesta a una pregunta
func (fr *FormResponse) AddAnswer(questionID, value string) error {
if questionID == "" {
return errors.New("questionID es requerido")
}
// Verificar que no exista ya una respuesta a esta pregunta
for _, a := range fr.Answers {
if a.QuestionID == questionID {
return errors.New("ya existe una respuesta para esta pregunta")
}
}
fr.Answers = append(fr.Answers, Answer{
QuestionID: questionID,
Value: value,
})
fr.UpdatedAt = time.Now()
return nil
}
2.4 Value Objects: Errores del Dominio
Crea internal/domain/errors.go:
package domain
import "fmt"
// DomainError es la base para errores específicos del dominio
type DomainError struct {
Code string
Message string
}
func (e DomainError) Error() string {
return fmt.Sprintf("[%s] %s", e.Code, e.Message)
}
// Errores específicos de negocio
type NotFoundError struct {
DomainError
EntityType string
ID string
}
func NewNotFoundError(entityType, id string) NotFoundError {
return NotFoundError{
DomainError: DomainError{
Code: "NOT_FOUND",
Message: fmt.Sprintf("%s con ID %s no encontrado", entityType, id),
},
EntityType: entityType,
ID: id,
}
}
type UnauthorizedError struct {
DomainError
Action string
}
func NewUnauthorizedError(action string) UnauthorizedError {
return UnauthorizedError{
DomainError: DomainError{
Code: "UNAUTHORIZED",
Message: fmt.Sprintf("no autorizado para: %s", action),
},
Action: action,
}
}
type InvalidStateError struct {
DomainError
State string
}
func NewInvalidStateError(state string) InvalidStateError {
return InvalidStateError{
DomainError: DomainError{
Code: "INVALID_STATE",
Message: fmt.Sprintf("estado inválido: %s", state),
},
State: state,
}
}
✅ Checkpoint: Tienes las entidades principales del dominio. Prueba que compila:
go build ./internal/domain
# No debe mostrar errores
PARTE 3: PUERTOS (INTERFACES)
Los puertos son las interfaces que definen cómo el dominio se comunica con el mundo exterior. El dominio no sabe cómo se implementan (eso es responsabilidad de los adaptadores).
3.1 Puerto: UserRepository
Crea internal/domain/repository.go:
package domain
import "context"
// UserRepository define el contrato para acceder a usuarios
// El dominio NO sabe si estamos usando PostgreSQL, MongoDB, etc
// Solo sabe que puede obtener/guardar usuarios
type UserRepository interface {
// SaveUser guarda un usuario nuevo o actualiza uno existente
SaveUser(ctx context.Context, user *User) error
// GetUserByID obtiene un usuario por su ID
GetUserByID(ctx context.Context, id string) (*User, error)
// GetUserByEmail obtiene un usuario por email (para login)
GetUserByEmail(ctx context.Context, email string) (*User, error)
// DeleteUser elimina un usuario
DeleteUser(ctx context.Context, id string) error
}
// FormRepository define el contrato para acceder a formularios
type FormRepository interface {
// SaveForm guarda un formulario nuevo o lo actualiza
SaveForm(ctx context.Context, form *Form) error
// GetFormByID obtiene un formulario completo con sus preguntas
GetFormByID(ctx context.Context, id string) (*Form, error)
// GetFormsByCreator obtiene todos los formularios creados por un usuario
GetFormsByCreator(ctx context.Context, creatorID string) ([]Form, error)
// DeleteForm elimina un formulario
DeleteForm(ctx context.Context, id string) error
}
// FormResponseRepository define el contrato para respuestas
type FormResponseRepository interface {
// SaveResponse guarda una nueva respuesta
SaveResponse(ctx context.Context, response *FormResponse) error
// GetResponsesByFormID obtiene todas las respuestas de un formulario
GetResponsesByFormID(ctx context.Context, formID string) ([]FormResponse, error)
// GetResponsesByUserID obtiene todas las respuestas de un usuario
GetResponsesByUserID(ctx context.Context, userID string) ([]FormResponse, error)
// GetResponse obtiene una respuesta específica
GetResponse(ctx context.Context, id string) (*FormResponse, error)
// DeleteResponse elimina una respuesta
DeleteResponse(ctx context.Context, id string) error
}
¿Por qué interfaces?
- Inversión de Dependencias: El dominio define qué necesita, no cómo implementarlo
- Testing Fácil: En tests, creas mocks que implementan estas interfaces
- Flexibilidad: Cambiar de PostgreSQL a MongoDB sin tocar el dominio
3.2 Puerto: TokenProvider (JWT)
Crea internal/domain/auth.go:
package domain
import "context"
// TokenProvider define cómo generar y validar tokens JWT
type TokenProvider interface {
// GenerateToken crea un JWT para un usuario
// Devuelve el token string y cualquier error
GenerateToken(ctx context.Context, user *User) (string, error)
// ValidateToken valida un JWT y devuelve el userID si es válido
ValidateToken(ctx context.Context, token string) (userID string, err error)
}
// PasswordHasher define cómo hashear contraseñas
type PasswordHasher interface {
// Hash hashea una contraseña plain text
Hash(ctx context.Context, password string) (string, error)
// Verify compara una contraseña plain text con un hash
Verify(ctx context.Context, hash, password string) error
}
✅ Checkpoint: Tus puertos (interfaces) están definidos. El dominio está completo e independiente de cualquier tecnología.
go build ./internal/domain
PARTE 4: ADAPTADORES - Parte 1: Base de Datos
Ahora implementamos los adaptadores: las capas técnicas que implementan los puertos.
4.1 Setup de Base de Datos
Primero, crea la base de datos PostgreSQL:
# Si tienes psql instalado:
createdb formhub
# O en un contenedor Docker (recomendado):
docker run -d \
--name formhub-postgres \
-e POSTGRES_USER=formhub \
-e POSTGRES_PASSWORD=secret \
-e POSTGRES_DB=formhub \
-p 5432:5432 \
postgres:15
Crea el schema (estructura de tablas):
Crea config/schema.sql:
-- Tabla de usuarios
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(36) PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
hashed_pw VARCHAR(255) NOT NULL,
role VARCHAR(50) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_users_email ON users(email);
-- Tabla de formularios
CREATE TABLE IF NOT EXISTS forms (
id VARCHAR(36) PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
created_by_id VARCHAR(36) NOT NULL REFERENCES users(id),
status VARCHAR(50) NOT NULL DEFAULT 'draft',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
published_at TIMESTAMP,
closed_at TIMESTAMP
);
CREATE INDEX idx_forms_created_by ON forms(created_by_id);
-- Tabla de preguntas
CREATE TABLE IF NOT EXISTS questions (
id VARCHAR(36) PRIMARY KEY,
form_id VARCHAR(36) NOT NULL REFERENCES forms(id) ON DELETE CASCADE,
title VARCHAR(255) NOT NULL,
type VARCHAR(50) NOT NULL,
required BOOLEAN DEFAULT FALSE,
"order" INTEGER NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_questions_form ON questions(form_id);
-- Tabla de opciones (para preguntas de tipo choice)
CREATE TABLE IF NOT EXISTS question_options (
id VARCHAR(36) PRIMARY KEY,
question_id VARCHAR(36) NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
option_text VARCHAR(255) NOT NULL,
"order" INTEGER NOT NULL
);
CREATE INDEX idx_options_question ON question_options(question_id);
-- Tabla de respuestas
CREATE TABLE IF NOT EXISTS form_responses (
id VARCHAR(36) PRIMARY KEY,
form_id VARCHAR(36) NOT NULL REFERENCES forms(id),
user_id VARCHAR(36) NOT NULL REFERENCES users(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(form_id, user_id) -- Un usuario solo puede responder una vez por formulario
);
CREATE INDEX idx_responses_form ON form_responses(form_id);
CREATE INDEX idx_responses_user ON form_responses(user_id);
-- Tabla de respuestas a preguntas
CREATE TABLE IF NOT EXISTS question_answers (
id VARCHAR(36) PRIMARY KEY,
response_id VARCHAR(36) NOT NULL REFERENCES form_responses(id) ON DELETE CASCADE,
question_id VARCHAR(36) NOT NULL REFERENCES questions(id),
value TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_answers_response ON question_answers(response_id);
CREATE INDEX idx_answers_question ON question_answers(question_id);
Ejecuta el schema:
# Si tienes conexión local
psql formhub -f config/schema.sql
# O con Docker
docker exec formhub-postgres psql -U formhub -d formhub -f /dev/stdin < config/schema.sql
Verifica:
psql formhub -c "\dt"
# Deberías ver las tablas creadas
4.2 Configuración de BD
Crea config/database.go:
package config
import (
"fmt"
"os"
_ "github.com/lib/pq"
"github.com/jmoiron/sqlx"
)
// NewDatabase crea una conexión a PostgreSQL
func NewDatabase() (*sqlx.DB, error) {
// Obtener variables de entorno
dbUser := os.Getenv("DB_USER")
if dbUser == "" {
dbUser = "formhub"
}
dbPassword := os.Getenv("DB_PASSWORD")
if dbPassword == "" {
dbPassword = "secret"
}
dbHost := os.Getenv("DB_HOST")
if dbHost == "" {
dbHost = "localhost"
}
dbPort := os.Getenv("DB_PORT")
if dbPort == "" {
dbPort = "5432"
}
dbName := os.Getenv("DB_NAME")
if dbName == "" {
dbName = "formhub"
}
// Construir connection string
psqlInfo := fmt.Sprintf(
"host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
dbHost, dbPort, dbUser, dbPassword, dbName,
)
// Conectar
db, err := sqlx.Connect("postgres", psqlInfo)
if err != nil {
return nil, fmt.Errorf("error conectando a BD: %w", err)
}
// Configurar connection pool
db.SetMaxOpenConns(25) // Máximo de conexiones
db.SetMaxIdleConns(5) // Mínimo de conexiones idle
// Verificar conexión
if err := db.Ping(); err != nil {
return nil, fmt.Errorf("error verificando conexión: %w", err)
}
return db, nil
}
4.3 Adaptador: PostgresUserRepository
Crea internal/adapter/repository/postgres_user.go:
package repository
import (
"context"
"errors"
"fmt"
"github.com/jmoiron/sqlx"
"github.com/tuusuario/formhub/internal/domain"
)
// PostgresUserRepository implementa domain.UserRepository usando PostgreSQL
type PostgresUserRepository struct {
db *sqlx.DB
}
// NewPostgresUserRepository crea una nueva instancia
func NewPostgresUserRepository(db *sqlx.DB) *PostgresUserRepository {
return &PostgresUserRepository{db: db}
}
// SaveUser guarda o actualiza un usuario
func (r *PostgresUserRepository) SaveUser(ctx context.Context, user *domain.User) error {
query := `
INSERT INTO users (id, email, name, hashed_pw, role, created_at, updated_at)
VALUES (:id, :email, :name, :hashed_pw, :role, :created_at, :updated_at)
ON CONFLICT (id) DO UPDATE SET
email = EXCLUDED.email,
name = EXCLUDED.name,
hashed_pw = EXCLUDED.hashed_pw,
role = EXCLUDED.role,
updated_at = EXCLUDED.updated_at
`
_, err := r.db.NamedExecContext(ctx, query, user)
if err != nil {
return fmt.Errorf("error guardando usuario: %w", err)
}
return nil
}
// GetUserByID obtiene un usuario por ID
func (r *PostgresUserRepository) GetUserByID(ctx context.Context, id string) (*domain.User, error) {
var user domain.User
query := `
SELECT id, email, name, hashed_pw, role, created_at, updated_at
FROM users
WHERE id = $1
`
err := r.db.GetContext(ctx, &user, query, id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.NewNotFoundError("User", id)
}
return nil, fmt.Errorf("error obteniendo usuario: %w", err)
}
return &user, nil
}
// GetUserByEmail obtiene un usuario por email
func (r *PostgresUserRepository) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
var user domain.User
query := `
SELECT id, email, name, hashed_pw, role, created_at, updated_at
FROM users
WHERE email = $1
`
err := r.db.GetContext(ctx, &user, query, email)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, domain.NewNotFoundError("User", email)
}
return nil, fmt.Errorf("error obteniendo usuario por email: %w", err)
}
return &user, nil
}
// DeleteUser elimina un usuario
func (r *PostgresUserRepository) DeleteUser(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, "DELETE FROM users WHERE id = $1", id)
if err != nil {
return fmt.Errorf("error eliminando usuario: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("error verificando eliminación: %w", err)
}
if rows == 0 {
return domain.NewNotFoundError("User", id)
}
return nil
}
Agregamos el import faltante en el archivo:
En la parte superior, add:
import (
"database/sql"
// ... otros imports
)
✅ Checkpoint: Tienes un adaptador de base de datos funcional. En la próxima sección continuaremos con más adaptadores (JWT, Bcrypt) y use cases.
go build ./internal/adapter/repository
4.4 Adaptador: PostgresFormRepository
Crea internal/adapter/repository/postgres_form.go:
package repository
import (
"context"
"fmt"
"github.com/jmoiron/sqlx"
"github.com/tuusuario/formhub/internal/domain"
)
// PostgresFormRepository implementa domain.FormRepository
type PostgresFormRepository struct {
db *sqlx.DB
}
// NewPostgresFormRepository crea una nueva instancia
func NewPostgresFormRepository(db *sqlx.DB) *PostgresFormRepository {
return &PostgresFormRepository{db: db}
}
// SaveForm guarda o actualiza un formulario
func (r *PostgresFormRepository) SaveForm(ctx context.Context, form *domain.Form) error {
if form == nil {
return fmt.Errorf("form cannot be nil")
}
query := `
INSERT INTO forms (id, title, description, created_by_id, status, created_at, updated_at)
VALUES (:id, :title, :description, :created_by_id, :status, :created_at, :updated_at)
ON CONFLICT (id) DO UPDATE SET
title = EXCLUDED.title,
description = EXCLUDED.description,
status = EXCLUDED.status,
updated_at = EXCLUDED.updated_at
`
_, err := r.db.NamedExecContext(ctx, query, form)
if err != nil {
return fmt.Errorf("error saving form: %w", err)
}
return nil
}
// GetFormByID obtiene un formulario por ID
func (r *PostgresFormRepository) GetFormByID(ctx context.Context, id string) (*domain.Form, error) {
var form domain.Form
query := `
SELECT id, title, description, created_by_id, status, created_at, updated_at, published_at, closed_at
FROM forms
WHERE id = $1
`
err := r.db.GetContext(ctx, &form, query, id)
if err != nil {
if err.Error() == "sql: no rows in result set" {
return nil, domain.NewNotFoundError("Form", id)
}
return nil, fmt.Errorf("error getting form: %w", err)
}
// TODO: Cargar preguntas asociadas
return &form, nil
}
// GetFormsByCreator obtiene formularios creados por un usuario
func (r *PostgresFormRepository) GetFormsByCreator(ctx context.Context, creatorID string) ([]domain.Form, error) {
var forms []domain.Form
query := `
SELECT id, title, description, created_by_id, status, created_at, updated_at, published_at, closed_at
FROM forms
WHERE created_by_id = $1
ORDER BY created_at DESC
`
err := r.db.SelectContext(ctx, &forms, query, creatorID)
if err != nil {
return nil, fmt.Errorf("error getting forms: %w", err)
}
return forms, nil
}
// DeleteForm elimina un formulario
func (r *PostgresFormRepository) DeleteForm(ctx context.Context, id string) error {
result, err := r.db.ExecContext(ctx, "DELETE FROM forms WHERE id = $1", id)
if err != nil {
return fmt.Errorf("error deleting form: %w", err)
}
rows, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("error checking deletion: %w", err)
}
if rows == 0 {
return domain.NewNotFoundError("Form", id)
}
return nil
}
✅ Checkpoint: Ahora tienes ambos repositorios (User y Form) implementados y funcionales.
go build ./internal/adapter/repository
PARTE 5: ADAPTADORES - Autenticación (JWT + Bcrypt)
Ahora implementamos cómo manejar contraseñas y autenticación con tokens JWT.
5.1 Adaptador: Bcrypt Password Hasher
Crea internal/adapter/auth/bcrypt_hasher.go:
package auth
import (
"context"
"fmt"
"golang.org/x/crypto/bcrypt"
"github.com/tuusuario/formhub/internal/domain"
)
// BcryptPasswordHasher implementa domain.PasswordHasher
type BcryptPasswordHasher struct {
cost int // Costo de hashing (más alto = más seguro pero más lento)
}
// NewBcryptPasswordHasher crea una nueva instancia
func NewBcryptPasswordHasher() *BcryptPasswordHasher {
return &BcryptPasswordHasher{
cost: bcrypt.DefaultCost, // 10 es el estándar
}
}
// Hash hashea una contraseña
// Bcrypt añade "salt" automáticamente
func (b *BcryptPasswordHasher) Hash(ctx context.Context, password string) (string, error) {
// Bcrypt es lento a propósito (ralentiza ataques de fuerza bruta)
hashedBytes, err := bcrypt.GenerateFromPassword([]byte(password), b.cost)
if err != nil {
return "", fmt.Errorf("error hashing password: %w", err)
}
return string(hashedBytes), nil
}
// Verify compara una contraseña plain text con un hash
// Devuelve error si no coinciden
func (b *BcryptPasswordHasher) Verify(ctx context.Context, hash, password string) error {
// Bcrypt compara automáticamente el salt
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
if err != nil {
// Bcrypt devuelve ErrMismatchedHashAndPassword si no coinciden
return fmt.Errorf("invalid password")
}
return nil
}
¿Por qué Bcrypt?
- Lento a propósito: Ralentiza ataques de fuerza bruta (toma ~100ms por hash)
- Salt automático: No necesitas generar salt manualmente
- Costo adaptable: Puedes aumentar costo conforme las computadoras se vuelven más rápidas
- Seguro: Estándar de la industria desde 1999
5.2 Adaptador: JWT Token Provider
Crea internal/adapter/auth/jwt_provider.go:
package auth
import (
"context"
"fmt"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/tuusuario/formhub/internal/domain"
)
// CustomClaims es la estructura de datos dentro del JWT
type CustomClaims struct {
UserID string `json:"user_id"`
Email string `json:"email"`
Role domain.Role `json:"role"`
jwt.RegisteredClaims
}
// JWTProvider implementa domain.TokenProvider
type JWTProvider struct {
secret string // Clave secreta para firmar tokens
expiresIn time.Duration // Cuánto dura el token
}
// NewJWTProvider crea una nueva instancia
func NewJWTProvider() *JWTProvider {
// Obtener clave secreta del environment
// En producción, esto debe venir de un secret manager
secret := os.Getenv("JWT_SECRET")
if secret == "" {
// Fallback para desarrollo (NUNCA usar en producción)
secret = "desarrollo-secret-cambiar-en-produccion"
}
// Tokens que expiran en 24 horas
expiresIn := 24 * time.Hour
return &JWTProvider{
secret: secret,
expiresIn: expiresIn,
}
}
// GenerateToken crea un JWT para un usuario
func (p *JWTProvider) GenerateToken(ctx context.Context, user *domain.User) (string, error) {
// Crear claims (datos dentro del token)
now := time.Now()
claims := CustomClaims{
UserID: user.ID,
Email: user.Email,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(p.expiresIn)),
IssuedAt: jwt.NewNumericDate(now),
Issuer: "formhub",
},
}
// Crear token
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
// Firmar token con la clave secreta
tokenString, err := token.SignedString([]byte(p.secret))
if err != nil {
return "", fmt.Errorf("error signing token: %w", err)
}
return tokenString, nil
}
// ValidateToken valida un JWT y devuelve el userID
func (p *JWTProvider) ValidateToken(ctx context.Context, tokenString string) (userID string, err error) {
// Parsear y validar token
token, err := jwt.ParseWithClaims(tokenString, &CustomClaims{}, func(token *jwt.Token) (interface{}, error) {
// Verificar que el método de firma es el esperado
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(p.secret), nil
})
if err != nil {
return "", fmt.Errorf("error parsing token: %w", err)
}
// Verificar que el token es válido y tiene claims
claims, ok := token.Claims.(*CustomClaims)
if !ok || !token.Valid {
return "", fmt.Errorf("invalid token")
}
return claims.UserID, nil
}
// GetClaimsFromToken extrae los claims del token
// Útil para middleware que necesita el rol del usuario
func (p *JWTProvider) GetClaimsFromToken(ctx context.Context, tokenString string) (*CustomClaims, error) {
token, err := jwt.ParseWithClaims(tokenString, &CustomClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method")
}
return []byte(p.secret), nil
})
if err != nil {
return nil, fmt.Errorf("error parsing token: %w", err)
}
claims, ok := token.Claims.(*CustomClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token")
}
return claims, nil
}
✅ Checkpoint: Tienes autenticación funcional. Prueba:
go build ./internal/adapter/auth
PARTE 6: USE CASES / APPLICATION SERVICES
Los use cases orquestan la lógica de negocio. Coordinan entre el dominio y los repositorios.
6.1 Use Case: Register (Registrar Usuario)
Crea internal/usecase/register_user.go:
package usecase
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/tuusuario/formhub/internal/domain"
)
// RegisterUserUseCase maneja el registro de nuevos usuarios
type RegisterUserUseCase struct {
userRepo domain.UserRepository
passwordHasher domain.PasswordHasher
}
// NewRegisterUserUseCase crea una nueva instancia
func NewRegisterUserUseCase(
userRepo domain.UserRepository,
passwordHasher domain.PasswordHasher,
) *RegisterUserUseCase {
return &RegisterUserUseCase{
userRepo: userRepo,
passwordHasher: passwordHasher,
}
}
// RegisterUserInput es lo que recibimos del cliente
type RegisterUserInput struct {
Email string
Name string
Password string
}
// Execute ejecuta el caso de uso
func (uc *RegisterUserUseCase) Execute(ctx context.Context, input *RegisterUserInput) (*domain.User, error) {
// Validación básica
if input.Email == "" || input.Name == "" || input.Password == "" {
return nil, fmt.Errorf("email, name y password son requeridos")
}
// Verificar que el usuario no existe ya
existingUser, err := uc.userRepo.GetUserByEmail(ctx, input.Email)
if err == nil && existingUser != nil {
return nil, fmt.Errorf("usuario con este email ya existe")
}
// Hash de la contraseña
hashedPassword, err := uc.passwordHasher.Hash(ctx, input.Password)
if err != nil {
return nil, fmt.Errorf("error processing password: %w", err)
}
// Crear nuevo usuario
// Los usuarios nuevos por defecto son "responder" (el más bajo de permisos)
user, err := domain.NewUser(
uuid.New().String(),
input.Email,
input.Name,
hashedPassword,
domain.RoleResponder, // Rol por defecto
)
if err != nil {
return nil, fmt.Errorf("error creating user: %w", err)
}
// Guardar en BD
if err := uc.userRepo.SaveUser(ctx, user); err != nil {
return nil, fmt.Errorf("error saving user: %w", err)
}
return user, nil
}
¿Por qué aquí?
- Orquestación: El use case coordina múltiples pasos
- Lógica de negocio: Validación, decisiones sobre roles, etc.
- Independencia: No sabe de HTTP, solo de dominio y repositorio
6.2 Use Case: Login (Autenticarse)
Crea internal/usecase/login_user.go:
package usecase
import (
"context"
"fmt"
"github.com/tuusuario/formhub/internal/domain"
)
// LoginUserUseCase maneja la autenticación
type LoginUserUseCase struct {
userRepo domain.UserRepository
passwordHasher domain.PasswordHasher
tokenProvider domain.TokenProvider
}
// NewLoginUserUseCase crea una nueva instancia
func NewLoginUserUseCase(
userRepo domain.UserRepository,
passwordHasher domain.PasswordHasher,
tokenProvider domain.TokenProvider,
) *LoginUserUseCase {
return &LoginUserUseCase{
userRepo: userRepo,
passwordHasher: passwordHasher,
tokenProvider: tokenProvider,
}
}
// LoginUserInput es lo que recibimos del cliente
type LoginUserInput struct {
Email string
Password string
}
// LoginUserOutput es lo que devolvemos al cliente
type LoginUserOutput struct {
Token string `json:"token"`
User *domain.User `json:"user"`
}
// Execute ejecuta el caso de uso
func (uc *LoginUserUseCase) Execute(ctx context.Context, input *LoginUserInput) (*LoginUserOutput, error) {
// Obtener usuario por email
user, err := uc.userRepo.GetUserByEmail(ctx, input.Email)
if err != nil {
// Nunca revelar si el usuario existe o no (seguridad)
return nil, fmt.Errorf("email o contraseña inválidos")
}
// Verificar contraseña
if err := uc.passwordHasher.Verify(ctx, user.HashedPw, input.Password); err != nil {
// Nunca revelar si es email o contraseña
return nil, fmt.Errorf("email o contraseña inválidos")
}
// Generar token
token, err := uc.tokenProvider.GenerateToken(ctx, user)
if err != nil {
return nil, fmt.Errorf("error generating token: %w", err)
}
return &LoginUserOutput{
Token: token,
User: user,
}, nil
}
6.3 Use Case: CreateForm (Crear Formulario)
Crea internal/usecase/create_form.go:
package usecase
import (
"context"
"fmt"
"github.com/google/uuid"
"github.com/tuusuario/formhub/internal/domain"
)
// CreateFormUseCase maneja la creación de formularios
type CreateFormUseCase struct {
formRepo domain.FormRepository
}
// NewCreateFormUseCase crea una nueva instancia
func NewCreateFormUseCase(formRepo domain.FormRepository) *CreateFormUseCase {
return &CreateFormUseCase{formRepo: formRepo}
}
// CreateFormInput es lo que recibimos del cliente
type CreateFormInput struct {
Title string
Description string
}
// Execute ejecuta el caso de uso
func (uc *CreateFormUseCase) Execute(ctx context.Context, creatorID string, input *CreateFormInput) (*domain.Form, error) {
// Validación
if input.Title == "" {
return nil, fmt.Errorf("título es requerido")
}
// Crear formulario
form, err := domain.NewForm(
uuid.New().String(),
input.Title,
input.Description,
creatorID,
)
if err != nil {
return nil, fmt.Errorf("error creating form: %w", err)
}
// Guardar
if err := uc.formRepo.SaveForm(ctx, form); err != nil {
return nil, fmt.Errorf("error saving form: %w", err)
}
return form, nil
}
PARTE 7: HTTP HANDLERS
Los handlers convierten requests HTTP a inputs de use cases y responden con JSON.
7.1 Estructura Base de Response
Crea internal/adapter/http/handler/response.go:
package handler
import (
"encoding/json"
"net/http"
)
// APIResponse es la estructura estándar de respuesta
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
}
// SuccessResponse escribe una respuesta exitosa
func SuccessResponse(w http.ResponseWriter, statusCode int, data interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
response := APIResponse{
Success: true,
Data: data,
}
json.NewEncoder(w).Encode(response)
}
// ErrorResponse escribe una respuesta de error
func ErrorResponse(w http.ResponseWriter, statusCode int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(statusCode)
response := APIResponse{
Success: false,
Error: message,
}
json.NewEncoder(w).Encode(response)
}
7.2 Handler: Register
Crea internal/adapter/http/handler/auth.go:
package handler
import (
"encoding/json"
"net/http"
"github.com/tuusuario/formhub/internal/usecase"
)
// RegisterHandler maneja POST /auth/register
type RegisterHandler struct {
registerUseCase *usecase.RegisterUserUseCase
}
// NewRegisterHandler crea una nueva instancia
func NewRegisterHandler(registerUseCase *usecase.RegisterUserUseCase) *RegisterHandler {
return &RegisterHandler{registerUseCase: registerUseCase}
}
// ServeHTTP implementa http.Handler
func (h *RegisterHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Solo POST permitido
if r.Method != http.MethodPost {
ErrorResponse(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
// Parsear request
var input usecase.RegisterUserInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid request body")
return
}
// Ejecutar use case
user, err := h.registerUseCase.Execute(r.Context(), &input)
if err != nil {
ErrorResponse(w, http.StatusBadRequest, err.Error())
return
}
// Responder con éxito (201 Created)
SuccessResponse(w, http.StatusCreated, user)
}
// LoginHandler maneja POST /auth/login
type LoginHandler struct {
loginUseCase *usecase.LoginUserUseCase
}
// NewLoginHandler crea una nueva instancia
func NewLoginHandler(loginUseCase *usecase.LoginUserUseCase) *LoginHandler {
return &LoginHandler{loginUseCase: loginUseCase}
}
// ServeHTTP implementa http.Handler
func (h *LoginHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
ErrorResponse(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
var input usecase.LoginUserInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid request body")
return
}
output, err := h.loginUseCase.Execute(r.Context(), &input)
if err != nil {
ErrorResponse(w, http.StatusUnauthorized, "invalid credentials")
return
}
SuccessResponse(w, http.StatusOK, output)
}
7.3 Handler: Create Form
Crea internal/adapter/http/handler/form.go:
package handler
import (
"encoding/json"
"net/http"
"github.com/tuusuario/formhub/internal/adapter/auth"
"github.com/tuusuario/formhub/internal/usecase"
)
// CreateFormHandler maneja POST /api/forms
type CreateFormHandler struct {
createFormUseCase *usecase.CreateFormUseCase
}
// NewCreateFormHandler crea una nueva instancia
func NewCreateFormHandler(
createFormUseCase *usecase.CreateFormUseCase,
) *CreateFormHandler {
return &CreateFormHandler{
createFormUseCase: createFormUseCase,
}
}
// ServeHTTP implementa http.Handler
func (h *CreateFormHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
ErrorResponse(w, http.StatusMethodNotAllowed, "method not allowed")
return
}
// Obtener userID del contexto (lo pone el middleware de auth)
userID, ok := r.Context().Value("userID").(string)
if !ok {
ErrorResponse(w, http.StatusUnauthorized, "unauthorized")
return
}
// Parsear request
var input usecase.CreateFormInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
ErrorResponse(w, http.StatusBadRequest, "invalid request body")
return
}
// Ejecutar use case
form, err := h.createFormUseCase.Execute(r.Context(), userID, &input)
if err != nil {
ErrorResponse(w, http.StatusBadRequest, err.Error())
return
}
SuccessResponse(w, http.StatusCreated, form)
}
PARTE 8: MIDDLEWARE
El middleware intercepta requests para autenticación, logging, CORS, etc.
8.1 Auth Middleware
Crea internal/adapter/http/middleware/auth.go:
package middleware
import (
"context"
"net/http"
"strings"
"github.com/tuusuario/formhub/internal/adapter/auth"
)
// AuthMiddleware verifica el JWT en el header Authorization
func AuthMiddleware(tokenProvider *auth.JWTProvider) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Obtener header Authorization
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "missing authorization header", http.StatusUnauthorized)
return
}
// Esperar formato "Bearer <token>"
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
http.Error(w, "invalid authorization header", http.StatusUnauthorized)
return
}
token := parts[1]
// Validar token
userID, err := tokenProvider.ValidateToken(r.Context(), token)
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
// Agregar userID al contexto para que lo use el handler
ctx := context.WithValue(r.Context(), "userID", userID)
// También guardar los claims para el middleware de roles
claims, _ := tokenProvider.GetClaimsFromToken(r.Context(), token)
ctx = context.WithValue(ctx, "userRole", claims.Role)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
8.2 Role Middleware
Crea internal/adapter/http/middleware/roles.go:
package middleware
import (
"net/http"
"github.com/tuusuario/formhub/internal/domain"
)
// RequireRole verifica que el usuario tiene el rol requerido
func RequireRole(requiredRoles ...domain.Role) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Obtener rol del contexto (lo pone AuthMiddleware)
userRole, ok := r.Context().Value("userRole").(domain.Role)
if !ok {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// Verificar que el rol está en la lista de roles permitidos
allowed := false
for _, role := range requiredRoles {
if userRole == role {
allowed = true
break
}
}
if !allowed {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
}
8.3 CORS Middleware
Crea internal/adapter/http/middleware/cors.go:
package middleware
import "net/http"
// CORSMiddleware permite requests desde cualquier origen (desarrollo)
func CORSMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Headers CORS
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
// Responder a preflight requests
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
PARTE 9: ROUTING Y BOOTSTRAP
Aquí armamos todo: routing, inyección de dependencias, y servidor.
9.1 Container de Dependencias
Crea internal/adapter/container.go:
package adapter
import (
"github.com/jmoiron/sqlx"
"github.com/tuusuario/formhub/internal/adapter/auth"
"github.com/tuusuario/formhub/internal/adapter/http/handler"
"github.com/tuusuario/formhub/internal/adapter/repository"
"github.com/tuusuario/formhub/internal/usecase"
)
// Container contiene todas las dependencias inyectadas
type Container struct {
// Adaptadores
UserRepository *repository.PostgresUserRepository
FormRepository *repository.PostgresFormRepository
PasswordHasher *auth.BcryptPasswordHasher
TokenProvider *auth.JWTProvider
// Use Cases
RegisterUserUC *usecase.RegisterUserUseCase
LoginUserUC *usecase.LoginUserUseCase
CreateFormUC *usecase.CreateFormUseCase
// Handlers
RegisterHandler *handler.RegisterHandler
LoginHandler *handler.LoginHandler
CreateFormHandler *handler.CreateFormHandler
}
// NewContainer inyecta todas las dependencias
func NewContainer(db *sqlx.DB) *Container {
// Adaptadores
userRepo := repository.NewPostgresUserRepository(db)
formRepo := repository.NewPostgresFormRepository(db)
passwordHasher := auth.NewBcryptPasswordHasher()
tokenProvider := auth.NewJWTProvider()
// Use Cases
registerUC := usecase.NewRegisterUserUseCase(userRepo, passwordHasher)
loginUC := usecase.NewLoginUserUseCase(userRepo, passwordHasher, tokenProvider)
createFormUC := usecase.NewCreateFormUseCase(formRepo)
// Handlers
registerHandler := handler.NewRegisterHandler(registerUC)
loginHandler := handler.NewLoginHandler(loginUC)
createFormHandler := handler.NewCreateFormHandler(createFormUC)
return &Container{
UserRepository: userRepo,
FormRepository: formRepo,
PasswordHasher: passwordHasher,
TokenProvider: tokenProvider,
RegisterUserUC: registerUC,
LoginUserUC: loginUC,
CreateFormUC: createFormUC,
RegisterHandler: registerHandler,
LoginHandler: loginHandler,
CreateFormHandler: createFormHandler,
}
}
9.2 Server Setup
Crea internal/server.go:
package internal
import (
"net/http"
"github.com/tuusuario/formhub/internal/adapter"
"github.com/tuusuario/formhub/internal/adapter/http/middleware"
"github.com/tuusuario/formhub/internal/domain"
)
// NewServer crea el servidor HTTP con todas las rutas
func NewServer(container *adapter.Container) http.Handler {
mux := http.NewServeMux()
// Rutas públicas (sin autenticación)
mux.Handle("POST /auth/register", container.RegisterHandler)
mux.Handle("POST /auth/login", container.LoginHandler)
// Rutas protegidas (requieren JWT)
createFormWithAuth := middleware.CORSMiddleware(
middleware.AuthMiddleware(container.TokenProvider)(
middleware.RequireRole(domain.RoleAdmin)(
container.CreateFormHandler,
),
),
)
mux.Handle("POST /api/forms", createFormWithAuth)
// Aplicar CORS globalmente
return middleware.CORSMiddleware(mux)
}
9.3 Main.go Completo
Actualiza cmd/api/main.go:
package main
import (
"fmt"
"log"
"net/http"
"os"
"github.com/tuusuario/formhub/config"
"github.com/tuusuario/formhub/internal"
"github.com/tuusuario/formhub/internal/adapter"
)
func main() {
// Conectar a BD
db, err := config.NewDatabase()
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
defer db.Close()
// Inyectar dependencias
container := adapter.NewContainer(db)
// Crear servidor
server := internal.NewServer(container)
// Puerto
port := os.Getenv("PORT")
if port == "" {
port = "8080"
}
fmt.Printf("🚀 FormHub iniciando en puerto %s\n", port)
// Iniciar servidor
if err := http.ListenAndServe(":"+port, server); err != nil {
log.Fatalf("Error starting server: %v", err)
}
}
✅ Checkpoint: Tu servidor compila y está listo para pruebas.
go build ./cmd/api
PARTE 10: TESTS
Tests para cada capa aseguran que todo funciona correctamente.
10.1 Test: Bcrypt Hasher
Crea internal/adapter/auth/bcrypt_hasher_test.go:
package auth
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestBcryptHasher(t *testing.T) {
hasher := NewBcryptPasswordHasher()
ctx := context.Background()
password := "test-password-123"
// Test 1: Hash debe ser diferente al original
t.Run("Hash produces different string", func(t *testing.T) {
hash, err := hasher.Hash(ctx, password)
assert.NoError(t, err)
assert.NotEqual(t, password, hash)
})
// Test 2: Verify con contraseña correcta
t.Run("Verify succeeds with correct password", func(t *testing.T) {
hash, _ := hasher.Hash(ctx, password)
err := hasher.Verify(ctx, hash, password)
assert.NoError(t, err)
})
// Test 3: Verify falla con contraseña incorrecta
t.Run("Verify fails with incorrect password", func(t *testing.T) {
hash, _ := hasher.Hash(ctx, password)
err := hasher.Verify(ctx, hash, "wrong-password")
assert.Error(t, err)
})
// Test 4: Hashes diferentes para misma contraseña (salt)
t.Run("Different hashes for same password (salt)", func(t *testing.T) {
hash1, _ := hasher.Hash(ctx, password)
hash2, _ := hasher.Hash(ctx, password)
assert.NotEqual(t, hash1, hash2)
// Pero ambos verifican correctamente
assert.NoError(t, hasher.Verify(ctx, hash1, password))
assert.NoError(t, hasher.Verify(ctx, hash2, password))
})
}
Ejecuta:
go test ./internal/adapter/auth -v
10.2 Test: Register Use Case
Crea internal/usecase/register_user_test.go:
package usecase
import (
"context"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
"github.com/tuusuario/formhub/internal/adapter/auth"
"github.com/tuusuario/formhub/internal/domain"
)
// MockUserRepository es un mock para testing
type MockUserRepository struct {
users map[string]*domain.User
}
func NewMockUserRepository() *MockUserRepository {
return &MockUserRepository{users: make(map[string]*domain.User)}
}
func (m *MockUserRepository) SaveUser(ctx context.Context, user *domain.User) error {
m.users[user.ID] = user
return nil
}
func (m *MockUserRepository) GetUserByID(ctx context.Context, id string) (*domain.User, error) {
if user, ok := m.users[id]; ok {
return user, nil
}
return nil, fmt.Errorf("user not found")
}
func (m *MockUserRepository) GetUserByEmail(ctx context.Context, email string) (*domain.User, error) {
for _, user := range m.users {
if user.Email == email {
return user, nil
}
}
return nil, fmt.Errorf("user not found")
}
func (m *MockUserRepository) DeleteUser(ctx context.Context, id string) error {
delete(m.users, id)
return nil
}
func TestRegisterUser(t *testing.T) {
mockRepo := NewMockUserRepository()
hasher := auth.NewBcryptPasswordHasher()
uc := NewRegisterUserUseCase(mockRepo, hasher)
ctx := context.Background()
t.Run("Register user successfully", func(t *testing.T) {
input := &RegisterUserInput{
Email: "test@example.com",
Name: "Test User",
Password: "password123",
}
user, err := uc.Execute(ctx, input)
assert.NoError(t, err)
assert.NotNil(t, user)
assert.Equal(t, input.Email, user.Email)
assert.Equal(t, input.Name, user.Name)
assert.Equal(t, domain.RoleResponder, user.Role)
})
t.Run("Reject duplicate email", func(t *testing.T) {
input := &RegisterUserInput{
Email: "test@example.com",
Name: "Another User",
Password: "password123",
}
_, err := uc.Execute(ctx, input)
assert.Error(t, err)
})
t.Run("Require all fields", func(t *testing.T) {
input := &RegisterUserInput{
Email: "test2@example.com",
// Name is empty
Password: "password123",
}
_, err := uc.Execute(ctx, input)
assert.Error(t, err)
})
}
Ejecuta:
go test ./internal/usecase -v
PARTE 11: CORRIENDO EL PROYECTO
11.1 Crear archivo .env
Crea .env en la raíz:
# Database
DB_USER=formhub
DB_PASSWORD=secret
DB_HOST=localhost
DB_PORT=5432
DB_NAME=formhub
# JWT
JWT_SECRET=tu-clave-secreta-super-segura-cambiar-en-produccion
# Server
PORT=8080
11.2 Cargar variables de entorno y correr
# Cargar .env
export $(cat .env | xargs)
# Ejecutar la aplicación
go run ./cmd/api
# Deberías ver:
# 🚀 FormHub iniciando en puerto 8080
11.3 Probar con curl
# 1. Registrarse
curl -X POST http://localhost:8080/auth/register \
-H "Content-Type: application/json" \
-d '{
"email": "admin@example.com",
"name": "Admin User",
"password": "admin123"
}'
# 2. Login
curl -X POST http://localhost:8080/auth/login \
-H "Content-Type: application/json" \
-d '{
"email": "admin@example.com",
"password": "admin123"
}'
# Copiar el token devuelto
# 3. Crear formulario (con token)
curl -X POST http://localhost:8080/api/forms \
-H "Content-Type: application/json" \
-H "Authorization: Bearer <TU_TOKEN_AQUI>" \
-d '{
"title": "Mi Primer Formulario",
"description": "Una descripción"
}'
11.4 Compilar binario
# Compilar para tu SO actual
go build -o formhub ./cmd/api
# Ejecutar binario
./formhub
# Compilar para Linux desde macOS/Windows
GOOS=linux GOARCH=amd64 go build -o formhub-linux ./cmd/api
# Compilar para Windows
GOOS=windows GOARCH=amd64 go build -o formhub.exe ./cmd/api
PRÓXIMOS PASOS
Lo que falta implementar (para expandir el sistema):
-
Más endpoints:
GET /api/forms- Listar formularios del usuarioGET /api/forms/{id}- Obtener formulario específicoPUT /api/forms/{id}- Actualizar formularioPOST /api/forms/{id}/publish- Publicar formularioPOST /api/forms/{id}/questions- Agregar preguntasPOST /api/forms/{id}/responses- Responder formularioGET /api/forms/{id}/responses- Ver respuestas (admin solo)
-
Persistencia de Preguntas: Implementar
PostgresFormRepositorycompleto -
Validación avanzada: Usar
go-playground/validatorpara validación declarativa -
Logging: Agregar logging estructurado con logrus
-
Error Handling: Mejores mensajes de error específicos
-
Rate Limiting: Proteger contra abuse
-
Dockerfile: Para containerizar la aplicación
-
Tests de integración: Tests con BD real
-
Documentación OpenAPI: Swagger/OpenAPI para documentar API
-
Deploy: A Heroku, Docker, o tu plataforma preferida
RESUMEN: Lo que construiste
✅ Dominio: Entidades ricas con lógica de negocio (User, Form, FormResponse)
✅ Puertos: Interfaces agnósticas a tecnología (UserRepository, TokenProvider, etc)
✅ Adaptadores: Implementaciones concretas (PostgreSQL, JWT, Bcrypt)
✅ Use Cases: Orquestación de lógica (Register, Login, CreateForm)
✅ Handlers: HTTP endpoints que reciben requests JSON
✅ Middleware: Autenticación, CORS, roles
✅ Inyección de Dependencias: Container que arma todo
✅ Tests: Casos de prueba para cada capa
✅ Documentación: Código limpio y comentado
Sistema Production-Ready:
- Escalable (puedes cambiar PostgreSQL a MongoDB con 1 cambio)
- Testeable (mocks de repositorios sin BD real)
- Mantenible (capas limpias, responsabilidades separadas)
- Seguro (JWT, Bcrypt, CORS, validación)
- Real (código verdadero y ejecutable)
¿Necesitas ayuda con alguna sección específica o quieres expandir a otro tema?
Tags
Artículos relacionados
API Versioning Strategies: Cómo Evolucionar APIs sin Romper Clientes
Una guía exhaustiva sobre estrategias de versionado de APIs: URL versioning vs Header versioning, cómo deprecar endpoints sin shock, migration patterns reales, handling de cambios backwards-incompatibles, y decisiones arquitectónicas que importan. Con 50+ ejemplos de código en Go.
Arquitectura de software: Más allá del código
Una guía completa sobre arquitectura de software explicada en lenguaje humano: patrones, organización, estructura y cómo construir sistemas que escalen con tu negocio.
Automatizando tu vida con Go CLI: Guía profesional para crear herramientas de línea de comandos escalables
Una guía exhaustiva y paso a paso sobre cómo crear herramientas CLI escalables con Go 1.25.5: desde lo básico hasta proyectos empresariales complejos con flags, configuración, logging, y ejemplos prácticos para Windows y Linux.