Go 1.25 API Médica: DDD, TDD, BDD, PostgreSQL, Redis — Sistema Clínico Completo
Construye una API de registros médicos lista para producción en Go 1.25 con DDD, TDD, BDD, escenarios Gherkin, PostgreSQL, Redis. Historial clínico, tableros y cada archivo explicado.
Por Qué un Sistema Médico
Cada tutorial de Go construye una lista de TODO. O un blog. O una app de notas. Dominios simples que esconden problemas reales de ingeniería.
Un sistema médico es diferente. Piensa en un hospital. Un paciente llega. Una enfermera registra sus signos vitales. Un doctor revisa el historial — cada visita pasada, cada diagnóstico, cada medicamento. El doctor escribe una nota clínica. Esa nota se mueve a través de un tablero: triage, en progreso, revisión, completado. Múltiples doctores ven al mismo paciente. Los permisos importan. La integridad de datos no es opcional — es un requisito legal.
Este dominio te fuerza a resolver problemas que los tutoriales planos nunca tocan: datos temporales (el historial médico es append-only), máquinas de estado (flujos clínicos), raíces de agregado (un paciente posee sus encuentros), acceso basado en roles, y pistas de auditoría.
Esta guía construye el sistema completo. No pseudocódigo. No diagramas con “implementación dejada como ejercicio.” Cada archivo. Cada test. Cada migración. Una API de registros médicos que podrías desplegar mañana.
El Dominio: Qué Estamos Construyendo
Un sistema de registros clínicos con cuatro contextos acotados:
Registro de Pacientes — registrar pacientes, mantener demografía, buscar por nombre o ID.
Historial Clínico — línea temporal append-only de encuentros, diagnósticos, signos vitales y notas. Nada se elimina. Todo tiene timestamp.
Tablero Clínico — un tablero estilo Kanban donde los encuentros se mueven por etapas: triage, en progreso, revisión, dado de alta.
Directorio de Proveedores — doctores y enfermeras con roles y especialidades.
graph LR
subgraph "Registro de Pacientes"
P[Paciente]
end
subgraph "Historial Clínico"
E[Encuentro]
V[Signos Vitales]
D[Diagnóstico]
N[Nota Clínica]
end
subgraph "Tablero Clínico"
B[Tablero]
C[Columna]
CARD[Tarjeta]
end
subgraph "Directorio de Proveedores"
PR[Proveedor]
end
P --> E
E --> V
E --> D
E --> N
PR --> E
E --> CARD
CARD --> C
C --> B
Parte 1: Estructura del Proyecto
clinical-api/
├── cmd/
│ └── api/
│ └── main.go
├── internal/
│ ├── domain/
│ │ ├── patient/
│ │ │ ├── patient.go
│ │ │ ├── patient_repository.go
│ │ │ └── patient_test.go
│ │ ├── encounter/
│ │ │ ├── encounter.go
│ │ │ ├── vitals.go
│ │ │ ├── diagnosis.go
│ │ │ ├── clinical_note.go
│ │ │ ├── encounter_repository.go
│ │ │ └── encounter_test.go
│ │ ├── board/
│ │ │ ├── board.go
│ │ │ ├── column.go
│ │ │ ├── card.go
│ │ │ ├── board_repository.go
│ │ │ └── board_test.go
│ │ ├── provider/
│ │ │ ├── provider.go
│ │ │ └── provider_repository.go
│ │ └── shared/
│ │ ├── errors.go
│ │ └── types.go
│ ├── application/
│ │ ├── patient/
│ │ ├── encounter/
│ │ └── board/
│ ├── infrastructure/
│ │ ├── postgres/
│ │ ├── redis/
│ │ └── config/
│ └── interfaces/
│ └── http/
├── test/
│ └── bdd/
│ ├── features/
│ │ ├── patient_registration.feature
│ │ ├── clinical_encounter.feature
│ │ └── board_workflow.feature
│ └── steps_test.go
├── migrations/
├── docker-compose.yml
├── Dockerfile
├── Makefile
└── go.mod
Cada contexto acotado obtiene su propio subdirectorio dentro de domain/ y application/. Cuando el equipo de registro de pacientes trabaja independientemente del equipo del tablero clínico, su código no colisiona. La estructura de directorios refleja la organización de las personas que construyen y mantienen el sistema.
Parte 2: Capa de Dominio — Paciente
Un paciente es una persona con un número de expediente médico (MRN), demografía e información de contacto. El dominio aplica que un paciente siempre tenga nombre y una fecha de nacimiento válida.
// internal/domain/patient/patient.go
package patient
import (
"time"
"github.com/google/uuid"
"github.com/yourorg/clinical-api/internal/domain/shared"
)
// Patient es la raíz de agregado para identidad del paciente.
type Patient struct {
ID uuid.UUID
MRN string // Número de Expediente Médico
FirstName string
LastName string
DateOfBirth time.Time
Gender Gender
Email string
Phone string
Address Address
BloodType string
Allergies []string
Active bool
CreatedAt time.Time
UpdatedAt time.Time
}
type Gender string
const (
GenderMale Gender = "male"
GenderFemale Gender = "female"
GenderOther Gender = "other"
GenderUnknown Gender = "unknown"
)
type Address struct {
Street string
City string
State string
ZipCode string
Country string
}
// NewPatient crea un paciente validado.
func NewPatient(
firstName, lastName string,
dob time.Time,
gender Gender,
email, phone string,
) (*Patient, error) {
if firstName == "" {
return nil, shared.ErrFirstNameRequired
}
if lastName == "" {
return nil, shared.ErrLastNameRequired
}
if dob.After(time.Now()) {
return nil, shared.ErrDOBInFuture
}
now := time.Now()
return &Patient{
ID: uuid.New(),
MRN: generateMRN(),
FirstName: firstName,
LastName: lastName,
DateOfBirth: dob,
Gender: gender,
Email: email,
Phone: phone,
Active: true,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
// Age calcula la edad actual del paciente en años.
func (p *Patient) Age() int {
now := time.Now()
age := now.Year() - p.DateOfBirth.Year()
if now.YearDay() < p.DateOfBirth.YearDay() {
age--
}
return age
}
// FullName retorna el nombre completo del paciente.
func (p *Patient) FullName() string {
return p.FirstName + " " + p.LastName
}
// Deactivate marca al paciente como inactivo (soft delete).
func (p *Patient) Deactivate() {
p.Active = false
p.UpdatedAt = time.Now()
}
func generateMRN() string {
id := uuid.New()
return "MRN-" + id.String()[:8]
}
El MRN no es el ID de la base de datos. Es un identificador legible que enfermeras y doctores usan en conversación. “Paciente MRN-a1b2c3d4 está en la sala 3.” El UUID es para uso interno del sistema. Esta distinción importa en salud.
Parte 3: Capa de Dominio — Encuentro Clínico e Historial
Un encuentro es una visita única. Es el contenedor para todo lo que sucede: se registran signos vitales, se hacen diagnósticos, se escriben notas. Un encuentro es append-only. No editas un signo vital pasado. Registras uno nuevo.
// internal/domain/encounter/encounter.go
package encounter
import (
"time"
"github.com/google/uuid"
"github.com/yourorg/clinical-api/internal/domain/shared"
)
type EncounterStatus string
const (
StatusTriaged EncounterStatus = "triaged"
StatusInProgress EncounterStatus = "in_progress"
StatusInReview EncounterStatus = "in_review"
StatusCompleted EncounterStatus = "completed"
StatusDischarged EncounterStatus = "discharged"
StatusCancelled EncounterStatus = "cancelled"
)
type Encounter struct {
ID uuid.UUID
PatientID uuid.UUID
ProviderID uuid.UUID
Status EncounterStatus
ChiefComplaint string
Vitals []Vitals
Diagnoses []Diagnosis
Notes []ClinicalNote
StartedAt time.Time
EndedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
func NewEncounter(patientID, providerID uuid.UUID, chiefComplaint string) (*Encounter, error) {
if chiefComplaint == "" {
return nil, shared.ErrChiefComplaintRequired
}
now := time.Now()
return &Encounter{
ID: uuid.New(),
PatientID: patientID,
ProviderID: providerID,
Status: StatusTriaged,
ChiefComplaint: chiefComplaint,
Vitals: make([]Vitals, 0),
Diagnoses: make([]Diagnosis, 0),
Notes: make([]ClinicalNote, 0),
StartedAt: now,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
var validTransitions = map[EncounterStatus]map[EncounterStatus]bool{
StatusTriaged: {StatusInProgress: true, StatusCancelled: true},
StatusInProgress: {StatusInReview: true, StatusCancelled: true},
StatusInReview: {StatusCompleted: true, StatusInProgress: true},
StatusCompleted: {StatusDischarged: true},
}
func (e *Encounter) TransitionTo(next EncounterStatus) error {
allowed, exists := validTransitions[e.Status]
if !exists {
return shared.ErrStatusTransitionForbidden
}
if !allowed[next] {
return shared.ErrStatusTransitionForbidden
}
e.Status = next
e.UpdatedAt = time.Now()
if next == StatusDischarged || next == StatusCompleted {
now := time.Now()
e.EndedAt = &now
}
return nil
}
Signos Vitales — Mediciones Clínicas Append-Only
// internal/domain/encounter/vitals.go
package encounter
import (
"fmt"
"time"
"github.com/google/uuid"
"github.com/yourorg/clinical-api/internal/domain/shared"
)
// Vitals representa un conjunto de signos vitales registrados en un momento.
// Los signos vitales son inmutables una vez creados.
type Vitals struct {
ID uuid.UUID
EncounterID uuid.UUID
RecordedByID uuid.UUID
HeartRateBPM int
SystolicBP int
DiastolicBP int
TemperatureCelsius float64
RespiratoryRate int
OxygenSaturation int
PainLevel int
Notes string
RecordedAt time.Time
}
func NewVitals(
encounterID, recordedByID uuid.UUID,
heartRate, systolic, diastolic int,
temperature float64,
respRate, oxygenSat, painLevel int,
notes string,
) (*Vitals, error) {
if heartRate < 20 || heartRate > 300 {
return nil, shared.ErrInvalidHeartRate
}
if systolic < 50 || systolic > 300 {
return nil, shared.ErrInvalidBloodPressure
}
if diastolic < 20 || diastolic > 200 {
return nil, shared.ErrInvalidBloodPressure
}
if temperature < 30.0 || temperature > 45.0 {
return nil, shared.ErrInvalidTemperature
}
if oxygenSat < 0 || oxygenSat > 100 {
return nil, shared.ErrInvalidOxygenSaturation
}
if painLevel < 0 || painLevel > 10 {
return nil, shared.ErrInvalidPainLevel
}
return &Vitals{
ID: uuid.New(),
EncounterID: encounterID,
RecordedByID: recordedByID,
HeartRateBPM: heartRate,
SystolicBP: systolic,
DiastolicBP: diastolic,
TemperatureCelsius: temperature,
RespiratoryRate: respRate,
OxygenSaturation: oxygenSat,
PainLevel: painLevel,
Notes: notes,
RecordedAt: time.Now(),
}, nil
}
func (v *Vitals) BloodPressureString() string {
return fmt.Sprintf("%d/%d", v.SystolicBP, v.DiastolicBP)
}
// IsCritical retorna true si algún signo vital está en rango peligroso.
func (v *Vitals) IsCritical() bool {
return v.HeartRateBPM < 40 || v.HeartRateBPM > 180 ||
v.SystolicBP < 70 || v.SystolicBP > 200 ||
v.OxygenSaturation < 90 ||
v.TemperatureCelsius > 40.0 || v.TemperatureCelsius < 34.0
}
func (e *Encounter) RecordVitals(vitals *Vitals) error {
if e.Status == StatusDischarged || e.Status == StatusCancelled {
return shared.ErrEncounterClosed
}
e.Vitals = append(e.Vitals, *vitals)
e.UpdatedAt = time.Now()
return nil
}
Nota que IsCritical() vive en el dominio. Cuando la saturación de oxígeno cae por debajo de 90%, el sistema lo sabe. Esto no es una verificación de base de datos. Es una regla de negocio que un doctor definió. El dominio codifica conocimiento médico.
Parte 4: Capa de Dominio — Tablero Clínico
Un tablero clínico es un tablero Kanban donde cada tarjeta representa un encuentro activo. Las columnas representan etapas del flujo de trabajo. Las tarjetas se mueven de izquierda a derecha a medida que avanza la atención.
// internal/domain/board/board.go
package board
import (
"time"
"github.com/google/uuid"
"github.com/yourorg/clinical-api/internal/domain/shared"
)
type Board struct {
ID uuid.UUID
Name string
Description string
Columns []Column
CreatedAt time.Time
UpdatedAt time.Time
}
func NewBoard(name, description string) (*Board, error) {
if name == "" {
return nil, shared.ErrBoardNameRequired
}
now := time.Now()
board := &Board{
ID: uuid.New(),
Name: name,
Description: description,
CreatedAt: now,
UpdatedAt: now,
}
defaultColumns := []string{"Triage", "In Progress", "Review", "Completed", "Discharged"}
for i, colName := range defaultColumns {
board.Columns = append(board.Columns, Column{
ID: uuid.New(),
BoardID: board.ID,
Name: colName,
Position: i,
Cards: make([]Card, 0),
})
}
return board, nil
}
// MoveCard mueve una tarjeta de una columna a otra en el tablero.
func (b *Board) MoveCard(cardID, targetColumnID uuid.UUID) error {
targetCol := b.FindColumnByID(targetColumnID)
if targetCol == nil {
return shared.ErrColumnNotFound
}
if !targetCol.CanAcceptCard() {
return shared.ErrColumnAtCapacity
}
var movedCard *Card
for i := range b.Columns {
for j := range b.Columns[i].Cards {
if b.Columns[i].Cards[j].ID == cardID {
movedCard = &b.Columns[i].Cards[j]
b.Columns[i].Cards = append(
b.Columns[i].Cards[:j],
b.Columns[i].Cards[j+1:]...,
)
break
}
}
if movedCard != nil {
break
}
}
if movedCard == nil {
return shared.ErrCardNotFound
}
movedCard.ColumnID = targetColumnID
movedCard.UpdatedAt = time.Now()
targetCol.Cards = append(targetCol.Cards, *movedCard)
b.UpdatedAt = time.Now()
return nil
}
func (b *Board) FindColumnByID(id uuid.UUID) *Column {
for i := range b.Columns {
if b.Columns[i].ID == id {
return &b.Columns[i]
}
}
return nil
}
// internal/domain/board/column.go
package board
import "github.com/google/uuid"
type Column struct {
ID uuid.UUID
BoardID uuid.UUID
Name string
Position int
WIPLimit int
Cards []Card
}
func (c *Column) CanAcceptCard() bool {
if c.WIPLimit == 0 {
return true
}
return len(c.Cards) < c.WIPLimit
}
El dominio del tablero codifica reglas Kanban. Una columna puede tener un límite WIP. Si “En Progreso” está limitado a 5 pacientes y ya tiene 5, el sistema rechaza el movimiento. Esto previene sobrecargar a los doctores. La regla de negocio vive en el dominio, no en una validación de UI.
Parte 5: TDD — Tests Table-Driven
// internal/domain/encounter/encounter_test.go
package encounter
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yourorg/clinical-api/internal/domain/shared"
)
func TestEncounterStatusTransitions(t *testing.T) {
tests := []struct {
name string
from EncounterStatus
to EncounterStatus
wantErr bool
}{
{"triage a in_progress", StatusTriaged, StatusInProgress, false},
{"triage a cancelled", StatusTriaged, StatusCancelled, false},
{"triage a completed (inválido)", StatusTriaged, StatusCompleted, true},
{"in_progress a in_review", StatusInProgress, StatusInReview, false},
{"in_review a completed", StatusInReview, StatusCompleted, false},
{"in_review regresa a in_progress", StatusInReview, StatusInProgress, false},
{"completed a discharged", StatusCompleted, StatusDischarged, false},
{"discharged a cualquier cosa (inválido)", StatusDischarged, StatusTriaged, true},
{"cancelled a cualquier cosa (inválido)", StatusCancelled, StatusTriaged, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
enc := &Encounter{Status: tt.from}
err := enc.TransitionTo(tt.to)
if tt.wantErr {
assert.ErrorIs(t, err, shared.ErrStatusTransitionForbidden)
} else {
assert.NoError(t, err)
assert.Equal(t, tt.to, enc.Status)
}
})
}
}
func TestVitalsValidation(t *testing.T) {
encounterID := uuid.New()
providerID := uuid.New()
tests := []struct {
name string
heartRate int
systolic int
diastolic int
temp float64
oxygenSat int
painLevel int
wantErr error
}{
{"signos válidos", 72, 120, 80, 36.6, 98, 3, nil},
{"frecuencia cardíaca muy baja", 10, 120, 80, 36.6, 98, 0, shared.ErrInvalidHeartRate},
{"frecuencia cardíaca muy alta", 350, 120, 80, 36.6, 98, 0, shared.ErrInvalidHeartRate},
{"sistólica muy baja", 72, 40, 80, 36.6, 98, 0, shared.ErrInvalidBloodPressure},
{"temperatura muy baja", 72, 120, 80, 25.0, 98, 0, shared.ErrInvalidTemperature},
{"saturación negativa", 72, 120, 80, 36.6, -1, 0, shared.ErrInvalidOxygenSaturation},
{"nivel de dolor muy alto", 72, 120, 80, 36.6, 98, 15, shared.ErrInvalidPainLevel},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v, err := NewVitals(
encounterID, providerID,
tt.heartRate, tt.systolic, tt.diastolic,
tt.temp, 16, tt.oxygenSat, tt.painLevel, "",
)
if tt.wantErr != nil {
assert.ErrorIs(t, err, tt.wantErr)
assert.Nil(t, v)
} else {
require.NoError(t, err)
assert.Equal(t, tt.heartRate, v.HeartRateBPM)
}
})
}
}
func TestVitalsCritical(t *testing.T) {
tests := []struct {
name string
vitals Vitals
critical bool
}{
{"signos normales", Vitals{HeartRateBPM: 72, SystolicBP: 120, OxygenSaturation: 98, TemperatureCelsius: 36.6}, false},
{"frecuencia baja", Vitals{HeartRateBPM: 35, SystolicBP: 120, OxygenSaturation: 98, TemperatureCelsius: 36.6}, true},
{"oxígeno bajo", Vitals{HeartRateBPM: 72, SystolicBP: 120, OxygenSaturation: 85, TemperatureCelsius: 36.6}, true},
{"fiebre alta", Vitals{HeartRateBPM: 72, SystolicBP: 120, OxygenSaturation: 98, TemperatureCelsius: 41.0}, true},
{"hipotermia", Vitals{HeartRateBPM: 72, SystolicBP: 120, OxygenSaturation: 98, TemperatureCelsius: 33.0}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.critical, tt.vitals.IsCritical())
})
}
}
Parte 6: BDD con Gherkin
Registro de Pacientes
# test/bdd/features/patient_registration.feature
Feature: Registro de Pacientes
Como enfermera en recepción
Quiero registrar nuevos pacientes
Para que puedan recibir atención médica
Scenario: Registrar un nuevo paciente con información completa
When registro un paciente con:
| first_name | Maria |
| last_name | Garcia |
| date_of_birth | 1990-06-15 |
| gender | female |
| email | maria@example.com |
| phone | +52-686-555-0100 |
Then el paciente debería estar registrado
And el paciente debería tener un número de expediente médico
Scenario: Buscar pacientes por nombre
Given un paciente "Carlos" "Hernandez" está registrado
And un paciente "Carlos" "Martinez" está registrado
When busco pacientes con "Carlos"
Then debería encontrar 2 pacientes
Encuentro Clínico
# test/bdd/features/clinical_encounter.feature
Feature: Encuentro Clínico
Como doctor
Quiero gestionar encuentros clínicos
Para rastrear visitas de pacientes e historial médico
Scenario: Registrar signos vitales críticos dispara advertencia
Given existe un encuentro para la paciente "Elena Ramirez"
When registro signos vitales:
| heart_rate | 42 |
| systolic_bp | 200 |
| oxygen_sat | 85 |
| temperature | 40.5 |
| pain_level | 9 |
Then los signos vitales deberían ser críticos
Scenario: No se puede modificar un encuentro dado de alta
Given existe un encuentro con status "discharged"
When intento registrar signos vitales
Then debería ver un error "encounter is closed"
Scenario: Flujo de trabajo del encuentro
Given existe un encuentro en status "triaged"
When transiciono el encuentro a "in_progress"
Then el status debería ser "in_progress"
When transiciono el encuentro a "in_review"
Then el status debería ser "in_review"
When transiciono el encuentro a "completed"
Then el status debería ser "completed"
Flujo de Trabajo del Tablero
# test/bdd/features/board_workflow.feature
Feature: Flujo de Trabajo del Tablero Clínico
Como enfermera jefe
Quiero un tablero visual de encuentros activos
Para gestionar el flujo de pacientes en el departamento
Scenario: El tablero tiene columnas por defecto
When veo el tablero "Urgencias"
Then debería ver columnas:
| Triage |
| In Progress |
| Review |
| Completed |
| Discharged |
Scenario: Límite WIP previene sobrecarga
Given la columna "In Progress" tiene límite WIP de 3
And ya existen 3 tarjetas en "In Progress"
When intento mover una tarjeta a "In Progress"
Then debería ver un error "column has reached its WIP limit"
Parte 7: Migraciones de Base de Datos
-- migrations/001_create_patients.up.sql
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE TABLE patients (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
mrn VARCHAR(20) NOT NULL UNIQUE,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
date_of_birth DATE NOT NULL,
gender VARCHAR(20) NOT NULL DEFAULT 'unknown',
email VARCHAR(255),
phone VARCHAR(30),
address JSONB NOT NULL DEFAULT '{}',
blood_type VARCHAR(5),
allergies TEXT[] NOT NULL DEFAULT '{}',
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_patients_mrn ON patients(mrn);
CREATE INDEX idx_patients_name ON patients(last_name, first_name);
CREATE INDEX idx_patients_active ON patients(active) WHERE active = true;
-- migrations/002_create_encounters.up.sql
CREATE TABLE encounters (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
patient_id UUID NOT NULL REFERENCES patients(id),
provider_id UUID NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'triaged',
chief_complaint TEXT NOT NULL,
started_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
ended_at TIMESTAMP WITH TIME ZONE,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE vitals (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
encounter_id UUID NOT NULL REFERENCES encounters(id),
recorded_by_id UUID NOT NULL,
heart_rate_bpm INT NOT NULL,
systolic_bp INT NOT NULL,
diastolic_bp INT NOT NULL,
temperature_celsius DECIMAL(4,1) NOT NULL,
respiratory_rate INT NOT NULL,
oxygen_saturation INT NOT NULL,
pain_level INT NOT NULL DEFAULT 0,
notes TEXT NOT NULL DEFAULT '',
recorded_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE diagnoses (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
encounter_id UUID NOT NULL REFERENCES encounters(id),
provider_id UUID NOT NULL,
icd_code VARCHAR(20) NOT NULL,
description TEXT NOT NULL,
severity VARCHAR(20) NOT NULL DEFAULT 'moderate',
is_primary BOOLEAN NOT NULL DEFAULT false,
diagnosed_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE TABLE clinical_notes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
encounter_id UUID NOT NULL REFERENCES encounters(id),
provider_id UUID NOT NULL,
note_type VARCHAR(20) NOT NULL DEFAULT 'progress',
content TEXT NOT NULL,
addendum_to UUID REFERENCES clinical_notes(id),
written_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_encounters_patient ON encounters(patient_id, created_at DESC);
CREATE INDEX idx_encounters_status ON encounters(status);
CREATE INDEX idx_vitals_encounter ON vitals(encounter_id, recorded_at DESC);
CREATE INDEX idx_diagnoses_encounter ON diagnoses(encounter_id);
CREATE INDEX idx_notes_encounter ON clinical_notes(encounter_id, written_at DESC);
Parte 8: Docker Compose y Makefile
# docker-compose.yml
services:
api:
build: .
ports:
- "8080:8080"
environment:
DATABASE_URL: postgres://clinical:secret@db:5432/clinical?sslmode=disable
REDIS_ADDR: redis:6379
LOG_LEVEL: debug
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
db:
image: postgres:17-alpine
environment:
POSTGRES_USER: clinical
POSTGRES_PASSWORD: secret
POSTGRES_DB: clinical
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U clinical"]
interval: 5s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
# Makefile
.PHONY: dev test test-bdd build docker-up docker-down
dev:
go run ./cmd/api
build:
CGO_ENABLED=0 go build -o bin/api ./cmd/api
test:
go test ./internal/... -v -race -count=1
test-bdd:
go test ./test/bdd/... -v
test-all: test test-bdd
migrate-up:
migrate -path migrations -database "$$DATABASE_URL" up
docker-up:
docker compose up -d
docker-down:
docker compose down -v
La Arquitectura Visualizada
graph TD
subgraph "Capa HTTP"
R[Chi Router]
PH[Patient Handler]
EH[Encounter Handler]
BH[Board Handler]
end
subgraph "Capa de Aplicación"
RP[Registrar Paciente]
SE[Iniciar Encuentro]
RV[Registrar Vitales]
AD[Agregar Diagnóstico]
WN[Escribir Nota]
GH[Obtener Historial]
MC[Mover Tarjeta]
end
subgraph "Capa de Dominio"
PAT[Entidad Paciente]
ENC[Agregado Encuentro]
VIT[Signos Vitales]
DIA[Diagnóstico]
NOTE[Nota Clínica]
BRD[Agregado Tablero]
end
subgraph "Infraestructura"
PG[(PostgreSQL)]
RD[(Redis)]
end
R --> PH & EH & BH
PH --> RP
EH --> SE & RV & AD & WN & GH
BH --> MC
RP --> PAT
SE --> ENC
RV --> ENC & VIT
AD --> DIA
WN --> NOTE
MC --> BRD
PAT -.-> PG
ENC -.-> PG
BRD -.-> PG
PAT -.-> RD
Por Qué un Dominio Médico Enseña Mejor Ingeniería
Un sistema médico expone patrones que dominios simples esconden:
Datos append-only — Los signos vitales y notas nunca se editan. Registras correcciones como addendums. Esto te fuerza a diseñar value objects inmutables y consultas temporales correctamente.
Máquinas de estado — Un encuentro se mueve a través de triage, en progreso, revisión, completado, dado de alta. El dominio aplica transiciones válidas. No puedes saltarte pasos. No puedes retroceder desde dado de alta.
Límites de agregado — Un paciente posee sus encuentros. Un encuentro posee sus signos vitales, diagnósticos y notas. La raíz de agregado aplica invariantes. No puedes agregar signos vitales a un encuentro cerrado porque el encuentro verifica su propio estado.
Límites WIP — El tablero aplica restricciones de capacidad. Una columna con límite WIP de 5 rechaza la sexta tarjeta. Esta es una restricción operacional real en departamentos de urgencias.
Detección crítica — El dominio de signos vitales sabe qué constituye una emergencia médica. Cuando la saturación de oxígeno cae por debajo de 90%, el sistema registra una advertencia. Reglas de negocio codificadas en el dominio, no en un trigger de base de datos.
Estos no son ejercicios académicos. Son los mismos patrones que hacen confiables a los sistemas financieros, plataformas logísticas y servicios gubernamentales. Un dominio médico solo hace las consecuencias viscerales — no puedes permitirte equivocarte cuando la vida de un paciente depende de los datos.
La arquitectura de software no es sobre elegancia. Es sobre codificar las reglas del mundo en el que vive tu software. Un sistema médico te fuerza a codificar reglas que importan: inmutabilidad, transiciones válidas, límites de capacidad, umbrales críticos. Si tu arquitectura no puede expresar estas restricciones, fallará exactamente cuando más importa.