Go 1.25 API Médica: DDD, TDD, BDD, PostgreSQL, Redis — Sistema Clínico Completo

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 Omar Flores

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.

Tags

#go #golang #rest-api #chi #ddd #domain-driven-design #tdd #bdd #testing #postgresql #redis #clean-code #architecture #backend #best-practices #performance