Go 1.25 Medical API: DDD, TDD, BDD, PostgreSQL, Redis — Complete Clinical System

Go 1.25 Medical API: DDD, TDD, BDD, PostgreSQL, Redis — Complete Clinical System

Build a production-ready medical records API in Go 1.25 with DDD, TDD, BDD, Gherkin scenarios, PostgreSQL, Redis. Full patient history, clinical boards, and every file explained.

By Omar Flores

Why a Medical System

Every Go tutorial builds a TODO list. Or a blog. Or a notes app. Simple domains that hide real engineering problems.

A medical system is different. Think of a hospital. A patient walks in. A nurse records their vitals. A doctor reviews the history — every past visit, every diagnosis, every medication. The doctor writes a new clinical note. That note moves through a board: triage, in-progress, review, completed. Multiple doctors see the same patient. Permissions matter. Data integrity is not optional — it is a legal requirement.

This domain forces you to solve problems that flat tutorials never touch: temporal data (medical history is append-only), state machines (clinical workflows), aggregate roots (a patient owns their encounters), role-based access, and audit trails.

This guide builds the entire system. Not pseudocode. Not diagrams with “implementation left as exercise.” Every file. Every test. Every migration. A medical records API that you could deploy tomorrow.


The Domain: What We Are Building

A clinical records system with four bounded contexts:

Patient Registry — register patients, maintain demographics, search by name or ID.

Clinical History — append-only timeline of encounters, diagnoses, vitals, and notes. Nothing is deleted. Everything is timestamped.

Clinical Board — a Kanban-style workflow board where encounters move through stages: triage, in-progress, review, discharged.

Provider Directory — doctors and nurses with roles and specialties.

graph LR
    subgraph "Patient Registry"
        P[Patient]
    end

    subgraph "Clinical History"
        E[Encounter]
        V[Vitals]
        D[Diagnosis]
        N[Clinical Note]
    end

    subgraph "Clinical Board"
        B[Board]
        C[Board Column]
        CARD[Board Card]
    end

    subgraph "Provider Directory"
        PR[Provider]
    end

    P --> E
    E --> V
    E --> D
    E --> N
    PR --> E
    E --> CARD
    CARD --> C
    C --> B

Part 1: Project Structure

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/
│   │   │   ├── register_patient.go
│   │   │   ├── get_patient.go
│   │   │   ├── search_patients.go
│   │   │   └── update_patient.go
│   │   ├── encounter/
│   │   │   ├── start_encounter.go
│   │   │   ├── record_vitals.go
│   │   │   ├── add_diagnosis.go
│   │   │   ├── write_note.go
│   │   │   └── get_history.go
│   │   └── board/
│   │       ├── create_board.go
│   │       ├── move_card.go
│   │       └── get_board.go
│   ├── infrastructure/
│   │   ├── postgres/
│   │   │   ├── connection.go
│   │   │   ├── patient_repo.go
│   │   │   ├── encounter_repo.go
│   │   │   └── board_repo.go
│   │   ├── redis/
│   │   │   ├── connection.go
│   │   │   └── patient_cache.go
│   │   └── config/
│   │       └── config.go
│   └── interfaces/
│       └── http/
│           ├── router.go
│           ├── patient_handler.go
│           ├── encounter_handler.go
│           ├── board_handler.go
│           ├── middleware.go
│           └── response.go
├── test/
│   └── bdd/
│       ├── features/
│       │   ├── patient_registration.feature
│       │   ├── clinical_encounter.feature
│       │   └── board_workflow.feature
│       └── steps_test.go
├── migrations/
│   ├── 001_create_patients.up.sql
│   ├── 001_create_patients.down.sql
│   ├── 002_create_encounters.up.sql
│   ├── 002_create_encounters.down.sql
│   ├── 003_create_boards.up.sql
│   ├── 003_create_boards.down.sql
│   ├── 004_create_providers.up.sql
│   └── 004_create_providers.down.sql
├── docker-compose.yml
├── Dockerfile
├── Makefile
└── go.mod

Each bounded context gets its own subdirectory inside domain/ and application/. This is not accidental. When the patient registry team works independently from the clinical board team, their code does not collide. The directory structure mirrors the organization of the people who build and maintain the system.


Part 2: Packages

// go.mod
module github.com/yourorg/clinical-api

go 1.25

require (
    github.com/go-chi/chi/v5 v5.2.1        // HTTP router
    github.com/jackc/pgx/v5 v5.7.4          // PostgreSQL driver
    github.com/redis/go-redis/v9 v9.7.3     // Redis client
    github.com/google/uuid v1.6.0           // UUID generation
    github.com/caarlos0/env/v11 v11.3.1     // Config from env vars
    github.com/golang-migrate/migrate/v4 v4.18.2  // SQL migrations
    github.com/go-playground/validator/v10 v10.24.0 // Struct validation
    github.com/cucumber/godog v0.15.0       // BDD framework
    github.com/stretchr/testify v1.10.0     // Test assertions
)

Every package was chosen to stay close to the standard library. Chi wraps net/http. pgx implements database/sql semantics. No ORM. No code generation. No magic. You own every query.


Part 3: Domain Layer — Patient

A patient is a person with a medical record number (MRN), demographics, and contact information. The domain enforces that a patient always has a name and a valid date of birth.

// internal/domain/patient/patient.go
package patient

import (
    "time"

    "github.com/google/uuid"
    "github.com/yourorg/clinical-api/internal/domain/shared"
)

// Patient is the aggregate root for patient identity.
type Patient struct {
    ID          uuid.UUID
    MRN         string // Medical Record Number — unique, system-generated
    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 creates a validated patient.
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
    }
    if time.Since(dob) < 0 {
        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 calculates the patient's current age in years.
func (p *Patient) Age() int {
    now := time.Now()
    age := now.Year() - p.DateOfBirth.Year()
    if now.YearDay() < p.DateOfBirth.YearDay() {
        age--
    }
    return age
}

// FullName returns the patient's full name.
func (p *Patient) FullName() string {
    return p.FirstName + " " + p.LastName
}

// Deactivate marks the patient as inactive (soft delete).
func (p *Patient) Deactivate() {
    p.Active = false
    p.UpdatedAt = time.Now()
}

// UpdateDemographics updates patient information.
func (p *Patient) UpdateDemographics(firstName, lastName, email, phone string) error {
    if firstName == "" {
        return shared.ErrFirstNameRequired
    }
    if lastName == "" {
        return shared.ErrLastNameRequired
    }
    p.FirstName = firstName
    p.LastName = lastName
    p.Email = email
    p.Phone = phone
    p.UpdatedAt = time.Now()
    return nil
}

// SetAllergies updates the patient's allergy list.
func (p *Patient) SetAllergies(allergies []string) {
    p.Allergies = allergies
    p.UpdatedAt = time.Now()
}

// generateMRN creates a unique medical record number.
func generateMRN() string {
    id := uuid.New()
    return "MRN-" + id.String()[:8]
}

The MRN is not the database ID. It is a human-readable identifier that nurses and doctors use in conversation. “Patient MRN-a1b2c3d4 is in room 3.” The UUID is for internal system use. This distinction matters in healthcare.

Patient Repository Interface

// internal/domain/patient/patient_repository.go
package patient

import (
    "context"

    "github.com/google/uuid"
)

type PatientRepository interface {
    Save(ctx context.Context, patient *Patient) error
    FindByID(ctx context.Context, id uuid.UUID) (*Patient, error)
    FindByMRN(ctx context.Context, mrn string) (*Patient, error)
    Search(ctx context.Context, query string, limit, offset int) ([]*Patient, int, error)
    Update(ctx context.Context, patient *Patient) error
    ListActive(ctx context.Context, limit, offset int) ([]*Patient, int, error)
}

Part 4: Domain Layer — Clinical Encounter and History

An encounter is a single visit. It is the container for everything that happens: vitals are recorded, diagnoses are made, notes are written. An encounter is append-only. You do not edit a past vital sign. You record a new one.

// internal/domain/encounter/encounter.go
package encounter

import (
    "time"

    "github.com/google/uuid"
    "github.com/yourorg/clinical-api/internal/domain/shared"
)

// EncounterStatus tracks the lifecycle of a clinical visit.
type EncounterStatus string

const (
    StatusTriaged    EncounterStatus = "triaged"
    StatusInProgress EncounterStatus = "in_progress"
    StatusInReview   EncounterStatus = "in_review"
    StatusCompleted  EncounterStatus = "completed"
    StatusDischarged EncounterStatus = "discharged"
    StatusCancelled  EncounterStatus = "cancelled"
)

// Encounter represents a single clinical visit.
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
}

// NewEncounter starts a new clinical visit.
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
}

// validTransitions defines which status changes are allowed.
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},
}

// TransitionTo advances the encounter through the workflow.
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
}

// Duration returns how long the encounter has lasted.
func (e *Encounter) Duration() time.Duration {
    end := time.Now()
    if e.EndedAt != nil {
        end = *e.EndedAt
    }
    return end.Sub(e.StartedAt)
}

Vitals — Append-Only Clinical Measurements

// internal/domain/encounter/vitals.go
package encounter

import (
    "time"

    "github.com/google/uuid"
    "github.com/yourorg/clinical-api/internal/domain/shared"
)

// Vitals represents a single set of vital signs recorded at a point in time.
// Vitals are immutable once created — you never edit a past measurement.
type Vitals struct {
    ID                uuid.UUID
    EncounterID       uuid.UUID
    RecordedByID      uuid.UUID // The provider who measured
    HeartRateBPM      int       // Beats per minute
    SystolicBP        int       // mmHg
    DiastolicBP       int       // mmHg
    TemperatureCelsius float64  // Celsius, one decimal
    RespiratoryRate   int       // Breaths per minute
    OxygenSaturation  int       // Percentage (0-100)
    PainLevel         int       // 0-10 scale
    Notes             string
    RecordedAt        time.Time
}

// NewVitals creates a validated set of vital signs.
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
}

// BloodPressureString returns the BP in "120/80" format.
func (v *Vitals) BloodPressureString() string {
    return fmt.Sprintf("%d/%d", v.SystolicBP, v.DiastolicBP)
}

// IsCritical returns true if any vital sign is in a dangerous range.
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
}

// RecordVitals adds vitals to the encounter.
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
}

Notice that IsCritical() lives in the domain. When oxygen saturation drops below 90%, the system knows. This is not a database check. It is a business rule that a doctor defined. The domain encodes medical knowledge.

Diagnosis

// internal/domain/encounter/diagnosis.go
package encounter

import (
    "time"

    "github.com/google/uuid"
    "github.com/yourorg/clinical-api/internal/domain/shared"
)

// DiagnosisSeverity indicates clinical urgency.
type DiagnosisSeverity string

const (
    SeverityLow      DiagnosisSeverity = "low"
    SeverityModerate DiagnosisSeverity = "moderate"
    SeverityHigh     DiagnosisSeverity = "high"
    SeverityCritical DiagnosisSeverity = "critical"
)

// Diagnosis represents a clinical finding.
type Diagnosis struct {
    ID           uuid.UUID
    EncounterID  uuid.UUID
    ProviderID   uuid.UUID
    ICDCode      string            // ICD-10 code (e.g., "J06.9" for acute upper respiratory infection)
    Description  string
    Severity     DiagnosisSeverity
    IsPrimary    bool              // Primary diagnosis for this encounter
    DiagnosedAt  time.Time
}

// NewDiagnosis creates a validated diagnosis.
func NewDiagnosis(
    encounterID, providerID uuid.UUID,
    icdCode, description string,
    severity DiagnosisSeverity,
    isPrimary bool,
) (*Diagnosis, error) {
    if icdCode == "" {
        return nil, shared.ErrICDCodeRequired
    }
    if description == "" {
        return nil, shared.ErrDiagnosisDescriptionRequired
    }

    return &Diagnosis{
        ID:          uuid.New(),
        EncounterID: encounterID,
        ProviderID:  providerID,
        ICDCode:     icdCode,
        Description: description,
        Severity:    severity,
        IsPrimary:   isPrimary,
        DiagnosedAt: time.Now(),
    }, nil
}

// AddDiagnosis attaches a diagnosis to the encounter.
func (e *Encounter) AddDiagnosis(diag *Diagnosis) error {
    if e.Status == StatusDischarged || e.Status == StatusCancelled {
        return shared.ErrEncounterClosed
    }

    // If this is primary, demote any existing primary
    if diag.IsPrimary {
        for i := range e.Diagnoses {
            e.Diagnoses[i].IsPrimary = false
        }
    }

    e.Diagnoses = append(e.Diagnoses, *diag)
    e.UpdatedAt = time.Now()
    return nil
}

Clinical Note

// internal/domain/encounter/clinical_note.go
package encounter

import (
    "time"

    "github.com/google/uuid"
    "github.com/yourorg/clinical-api/internal/domain/shared"
)

// NoteType classifies the clinical note.
type NoteType string

const (
    NoteTypeProgress   NoteType = "progress"
    NoteTypeAdmission  NoteType = "admission"
    NoteTypeDischarge  NoteType = "discharge"
    NoteTypeProcedure  NoteType = "procedure"
    NoteTypeConsult    NoteType = "consult"
)

// ClinicalNote is a provider's written observation.
// Notes are immutable. To correct a note, you add an addendum.
type ClinicalNote struct {
    ID          uuid.UUID
    EncounterID uuid.UUID
    ProviderID  uuid.UUID
    NoteType    NoteType
    Content     string
    AddendumTo  *uuid.UUID // If this note corrects a previous one
    WrittenAt   time.Time
}

// NewClinicalNote creates a validated clinical note.
func NewClinicalNote(
    encounterID, providerID uuid.UUID,
    noteType NoteType,
    content string,
) (*ClinicalNote, error) {
    if content == "" {
        return nil, shared.ErrNoteContentRequired
    }
    if len(content) < 10 {
        return nil, shared.ErrNoteTooShort
    }

    return &ClinicalNote{
        ID:          uuid.New(),
        EncounterID: encounterID,
        ProviderID:  providerID,
        NoteType:    noteType,
        Content:     content,
        WrittenAt:   time.Now(),
    }, nil
}

// NewAddendum creates a correction note linked to a previous note.
func NewAddendum(
    encounterID, providerID, originalNoteID uuid.UUID,
    content string,
) (*ClinicalNote, error) {
    note, err := NewClinicalNote(encounterID, providerID, NoteTypeProgress, content)
    if err != nil {
        return nil, err
    }
    note.AddendumTo = &originalNoteID
    return note, nil
}

// WriteNote adds a clinical note to the encounter.
func (e *Encounter) WriteNote(note *ClinicalNote) error {
    if e.Status == StatusDischarged || e.Status == StatusCancelled {
        return shared.ErrEncounterClosed
    }
    e.Notes = append(e.Notes, *note)
    e.UpdatedAt = time.Now()
    return nil
}

Encounter Repository

// internal/domain/encounter/encounter_repository.go
package encounter

import (
    "context"

    "github.com/google/uuid"
)

type EncounterRepository interface {
    Save(ctx context.Context, encounter *Encounter) error
    FindByID(ctx context.Context, id uuid.UUID) (*Encounter, error)
    FindByPatientID(ctx context.Context, patientID uuid.UUID, limit, offset int) ([]*Encounter, int, error)
    Update(ctx context.Context, encounter *Encounter) error
    SaveVitals(ctx context.Context, vitals *Vitals) error
    SaveDiagnosis(ctx context.Context, diagnosis *Diagnosis) error
    SaveNote(ctx context.Context, note *ClinicalNote) error
    FindActiveByProviderID(ctx context.Context, providerID uuid.UUID) ([]*Encounter, error)
}

Part 5: Domain Layer — Clinical Board

A clinical board is a Kanban board where each card represents an active encounter. Columns represent workflow stages. Cards move left to right as care progresses.

// internal/domain/board/board.go
package board

import (
    "time"

    "github.com/google/uuid"
    "github.com/yourorg/clinical-api/internal/domain/shared"
)

// Board represents a clinical workflow board (like a Kanban board).
type Board struct {
    ID          uuid.UUID
    Name        string
    Description string
    Columns     []Column
    CreatedAt   time.Time
    UpdatedAt   time.Time
}

// NewBoard creates a clinical board with default columns.
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,
    }

    // Default clinical workflow columns
    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
}

// FindColumn returns a column by name.
func (b *Board) FindColumn(name string) *Column {
    for i := range b.Columns {
        if b.Columns[i].Name == name {
            return &b.Columns[i]
        }
    }
    return nil
}

// FindColumnByID returns a column by ID.
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"

// Column represents a stage in the clinical workflow.
type Column struct {
    ID       uuid.UUID
    BoardID  uuid.UUID
    Name     string
    Position int
    WIPLimit int // Work-in-progress limit (0 = unlimited)
    Cards    []Card
}

// CanAcceptCard returns true if the column has capacity.
func (c *Column) CanAcceptCard() bool {
    if c.WIPLimit == 0 {
        return true
    }
    return len(c.Cards) < c.WIPLimit
}
// internal/domain/board/card.go
package board

import (
    "time"

    "github.com/google/uuid"
    "github.com/yourorg/clinical-api/internal/domain/shared"
)

// CardPriority indicates clinical urgency on the board.
type CardPriority string

const (
    PriorityLow      CardPriority = "low"
    PriorityNormal   CardPriority = "normal"
    PriorityHigh     CardPriority = "high"
    PriorityCritical CardPriority = "critical"
)

// Card represents an encounter on the board.
type Card struct {
    ID           uuid.UUID
    ColumnID     uuid.UUID
    EncounterID  uuid.UUID
    PatientName  string
    ProviderName string
    Priority     CardPriority
    Summary      string
    Position     int
    CreatedAt    time.Time
    UpdatedAt    time.Time
}

// NewCard creates a board card for an encounter.
func NewCard(
    columnID, encounterID uuid.UUID,
    patientName, providerName, summary string,
    priority CardPriority,
) (*Card, error) {
    if patientName == "" {
        return nil, shared.ErrPatientNameRequired
    }

    now := time.Now()
    return &Card{
        ID:           uuid.New(),
        ColumnID:     columnID,
        EncounterID:  encounterID,
        PatientName:  patientName,
        ProviderName: providerName,
        Priority:     priority,
        Summary:      summary,
        CreatedAt:    now,
        UpdatedAt:    now,
    }, nil
}

// MoveCard moves a card from one column to another on the board.
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
    }

    // Find and remove card from current column
    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
}

The board domain encodes Kanban rules. A column can have a WIP limit. If “In Progress” is limited to 5 patients and already has 5, the system rejects the move. This prevents overloading doctors. The business rule lives in the domain, not in a UI validation.


Part 6: Shared Domain Errors

// internal/domain/shared/errors.go
package shared

import "errors"

// Patient errors
var (
    ErrFirstNameRequired = errors.New("first name is required")
    ErrLastNameRequired  = errors.New("last name is required")
    ErrDOBInFuture       = errors.New("date of birth cannot be in the future")
    ErrPatientNotFound   = errors.New("patient not found")
    ErrPatientInactive   = errors.New("patient is inactive")
)

// Encounter errors
var (
    ErrChiefComplaintRequired      = errors.New("chief complaint is required")
    ErrEncounterNotFound           = errors.New("encounter not found")
    ErrEncounterClosed             = errors.New("encounter is closed, no further modifications allowed")
    ErrStatusTransitionForbidden   = errors.New("this status transition is not allowed")
)

// Vitals errors
var (
    ErrInvalidHeartRate        = errors.New("heart rate must be between 20 and 300 BPM")
    ErrInvalidBloodPressure    = errors.New("blood pressure is out of valid range")
    ErrInvalidTemperature      = errors.New("temperature must be between 30 and 45 Celsius")
    ErrInvalidOxygenSaturation = errors.New("oxygen saturation must be between 0 and 100")
    ErrInvalidPainLevel        = errors.New("pain level must be between 0 and 10")
)

// Diagnosis errors
var (
    ErrICDCodeRequired              = errors.New("ICD-10 code is required")
    ErrDiagnosisDescriptionRequired = errors.New("diagnosis description is required")
)

// Note errors
var (
    ErrNoteContentRequired = errors.New("note content is required")
    ErrNoteTooShort        = errors.New("note must be at least 10 characters")
)

// Board errors
var (
    ErrBoardNameRequired   = errors.New("board name is required")
    ErrBoardNotFound       = errors.New("board not found")
    ErrColumnNotFound      = errors.New("column not found")
    ErrColumnAtCapacity    = errors.New("column has reached its WIP limit")
    ErrCardNotFound        = errors.New("card not found")
    ErrPatientNameRequired = errors.New("patient name is required")
)

Part 7: Application Layer — Use Cases

Start Encounter

// internal/application/encounter/start_encounter.go
package encounter

import (
    "context"
    "fmt"
    "log/slog"

    "github.com/google/uuid"
    "github.com/yourorg/clinical-api/internal/domain/encounter"
    "github.com/yourorg/clinical-api/internal/domain/patient"
    "github.com/yourorg/clinical-api/internal/domain/shared"
)

type StartEncounterCommand struct {
    PatientID      uuid.UUID
    ProviderID     uuid.UUID
    ChiefComplaint string
}

type StartEncounterHandler struct {
    encounterRepo encounter.EncounterRepository
    patientRepo   patient.PatientRepository
    logger        *slog.Logger
}

func NewStartEncounterHandler(
    er encounter.EncounterRepository,
    pr patient.PatientRepository,
    logger *slog.Logger,
) *StartEncounterHandler {
    return &StartEncounterHandler{
        encounterRepo: er,
        patientRepo:   pr,
        logger:        logger,
    }
}

func (h *StartEncounterHandler) Handle(ctx context.Context, cmd StartEncounterCommand) (*encounter.Encounter, error) {
    // Verify patient exists and is active
    pat, err := h.patientRepo.FindByID(ctx, cmd.PatientID)
    if err != nil {
        return nil, fmt.Errorf("finding patient: %w", err)
    }
    if !pat.Active {
        return nil, shared.ErrPatientInactive
    }

    // Create the encounter through the domain
    enc, err := encounter.NewEncounter(cmd.PatientID, cmd.ProviderID, cmd.ChiefComplaint)
    if err != nil {
        return nil, err
    }

    if err := h.encounterRepo.Save(ctx, enc); err != nil {
        return nil, fmt.Errorf("saving encounter: %w", err)
    }

    h.logger.Info("encounter started",
        slog.String("encounter_id", enc.ID.String()),
        slog.String("patient_id", cmd.PatientID.String()),
        slog.String("provider_id", cmd.ProviderID.String()),
        slog.String("chief_complaint", cmd.ChiefComplaint),
    )

    return enc, nil
}

Record Vitals

// internal/application/encounter/record_vitals.go
package encounter

import (
    "context"
    "fmt"
    "log/slog"

    "github.com/google/uuid"
    "github.com/yourorg/clinical-api/internal/domain/encounter"
)

type RecordVitalsCommand struct {
    EncounterID        uuid.UUID
    RecordedByID       uuid.UUID
    HeartRateBPM       int
    SystolicBP         int
    DiastolicBP        int
    TemperatureCelsius float64
    RespiratoryRate    int
    OxygenSaturation   int
    PainLevel          int
    Notes              string
}

type RecordVitalsHandler struct {
    encounterRepo encounter.EncounterRepository
    logger        *slog.Logger
}

func NewRecordVitalsHandler(
    er encounter.EncounterRepository,
    logger *slog.Logger,
) *RecordVitalsHandler {
    return &RecordVitalsHandler{encounterRepo: er, logger: logger}
}

func (h *RecordVitalsHandler) Handle(ctx context.Context, cmd RecordVitalsCommand) (*encounter.Vitals, error) {
    enc, err := h.encounterRepo.FindByID(ctx, cmd.EncounterID)
    if err != nil {
        return nil, fmt.Errorf("finding encounter: %w", err)
    }

    // Create vitals through the domain (validation happens here)
    vitals, err := encounter.NewVitals(
        cmd.EncounterID, cmd.RecordedByID,
        cmd.HeartRateBPM, cmd.SystolicBP, cmd.DiastolicBP,
        cmd.TemperatureCelsius,
        cmd.RespiratoryRate, cmd.OxygenSaturation, cmd.PainLevel,
        cmd.Notes,
    )
    if err != nil {
        return nil, err
    }

    // Add to encounter (domain validates encounter is still open)
    if err := enc.RecordVitals(vitals); err != nil {
        return nil, err
    }

    // Persist the vitals
    if err := h.encounterRepo.SaveVitals(ctx, vitals); err != nil {
        return nil, fmt.Errorf("saving vitals: %w", err)
    }

    if vitals.IsCritical() {
        h.logger.Warn("CRITICAL vitals recorded",
            slog.String("encounter_id", cmd.EncounterID.String()),
            slog.Int("heart_rate", vitals.HeartRateBPM),
            slog.Int("oxygen_sat", vitals.OxygenSaturation),
            slog.Float64("temperature", vitals.TemperatureCelsius),
        )
    }

    return vitals, nil
}

Get Patient History

// internal/application/encounter/get_history.go
package encounter

import (
    "context"
    "fmt"
    "log/slog"

    "github.com/google/uuid"
    "github.com/yourorg/clinical-api/internal/domain/encounter"
    "github.com/yourorg/clinical-api/internal/domain/patient"
)

type PatientHistory struct {
    Patient    *patient.Patient
    Encounters []*encounter.Encounter
    Total      int
}

type GetHistoryHandler struct {
    patientRepo   patient.PatientRepository
    encounterRepo encounter.EncounterRepository
    logger        *slog.Logger
}

func NewGetHistoryHandler(
    pr patient.PatientRepository,
    er encounter.EncounterRepository,
    logger *slog.Logger,
) *GetHistoryHandler {
    return &GetHistoryHandler{patientRepo: pr, encounterRepo: er, logger: logger}
}

func (h *GetHistoryHandler) Handle(ctx context.Context, patientID uuid.UUID, limit, offset int) (*PatientHistory, error) {
    pat, err := h.patientRepo.FindByID(ctx, patientID)
    if err != nil {
        return nil, fmt.Errorf("finding patient: %w", err)
    }

    encounters, total, err := h.encounterRepo.FindByPatientID(ctx, patientID, limit, offset)
    if err != nil {
        return nil, fmt.Errorf("finding encounters: %w", err)
    }

    return &PatientHistory{
        Patient:    pat,
        Encounters: encounters,
        Total:      total,
    }, nil
}

Part 8: Database Migrations

-- 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;
CREATE INDEX idx_patients_search ON patients USING GIN (
    to_tsvector('english', first_name || ' ' || last_name || ' ' || mrn)
);
-- 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_provider ON encounters(provider_id) WHERE status NOT IN ('discharged', 'cancelled');
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_diagnoses_icd ON diagnoses(icd_code);
CREATE INDEX idx_notes_encounter ON clinical_notes(encounter_id, written_at DESC);
-- migrations/003_create_boards.up.sql
CREATE TABLE boards (
    id          UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    name        VARCHAR(100) NOT NULL,
    description TEXT NOT NULL DEFAULT '',
    created_at  TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at  TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

CREATE TABLE board_columns (
    id        UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    board_id  UUID NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
    name      VARCHAR(50) NOT NULL,
    position  INT NOT NULL DEFAULT 0,
    wip_limit INT NOT NULL DEFAULT 0
);

CREATE TABLE board_cards (
    id            UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    column_id     UUID NOT NULL REFERENCES board_columns(id) ON DELETE CASCADE,
    encounter_id  UUID NOT NULL REFERENCES encounters(id),
    patient_name  VARCHAR(255) NOT NULL,
    provider_name VARCHAR(255) NOT NULL DEFAULT '',
    priority      VARCHAR(20) NOT NULL DEFAULT 'normal',
    summary       TEXT NOT NULL DEFAULT '',
    position      INT NOT NULL DEFAULT 0,
    created_at    TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
    updated_at    TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_board_columns_board ON board_columns(board_id, position);
CREATE INDEX idx_board_cards_column ON board_cards(column_id, position);
CREATE INDEX idx_board_cards_encounter ON board_cards(encounter_id);
-- migrations/004_create_providers.up.sql
CREATE TABLE providers (
    id         UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    first_name VARCHAR(100) NOT NULL,
    last_name  VARCHAR(100) NOT NULL,
    role       VARCHAR(50) NOT NULL,
    specialty  VARCHAR(100) NOT NULL DEFAULT '',
    license    VARCHAR(50) NOT NULL UNIQUE,
    active     BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);

CREATE INDEX idx_providers_role ON providers(role) WHERE active = true;
CREATE INDEX idx_providers_license ON providers(license);

Part 9: TDD — Table-Driven Tests

Patient Domain Tests

// internal/domain/patient/patient_test.go
package patient

import (
    "testing"
    "time"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
    "github.com/yourorg/clinical-api/internal/domain/shared"
)

func TestNewPatient(t *testing.T) {
    validDOB := time.Date(1990, 6, 15, 0, 0, 0, 0, time.UTC)

    tests := []struct {
        name      string
        firstName string
        lastName  string
        dob       time.Time
        gender    Gender
        wantErr   error
    }{
        {
            name:      "valid patient",
            firstName: "Maria",
            lastName:  "Garcia",
            dob:       validDOB,
            gender:    GenderFemale,
            wantErr:   nil,
        },
        {
            name:      "missing first name",
            firstName: "",
            lastName:  "Garcia",
            dob:       validDOB,
            gender:    GenderFemale,
            wantErr:   shared.ErrFirstNameRequired,
        },
        {
            name:      "missing last name",
            firstName: "Maria",
            lastName:  "",
            dob:       validDOB,
            gender:    GenderFemale,
            wantErr:   shared.ErrLastNameRequired,
        },
        {
            name:      "future date of birth",
            firstName: "Maria",
            lastName:  "Garcia",
            dob:       time.Now().Add(24 * time.Hour),
            gender:    GenderFemale,
            wantErr:   shared.ErrDOBInFuture,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            p, err := NewPatient(tt.firstName, tt.lastName, tt.dob, tt.gender, "", "")

            if tt.wantErr != nil {
                assert.ErrorIs(t, err, tt.wantErr)
                assert.Nil(t, p)
            } else {
                require.NoError(t, err)
                assert.Equal(t, tt.firstName, p.FirstName)
                assert.True(t, p.Active)
                assert.NotEmpty(t, p.MRN)
            }
        })
    }
}

func TestPatientAge(t *testing.T) {
    tests := []struct {
        name string
        dob  time.Time
        want int
    }{
        {
            name: "30 year old",
            dob:  time.Now().AddDate(-30, 0, 0),
            want: 30,
        },
        {
            name: "newborn",
            dob:  time.Now(),
            want: 0,
        },
        {
            name: "birthday tomorrow",
            dob:  time.Now().AddDate(-25, 0, 1),
            want: 24,
        },
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            p := &Patient{DateOfBirth: tt.dob}
            assert.Equal(t, tt.want, p.Age())
        })
    }
}

Encounter Domain Tests

// 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 to in_progress", StatusTriaged, StatusInProgress, false},
        {"triage to cancelled", StatusTriaged, StatusCancelled, false},
        {"triage to completed (invalid)", StatusTriaged, StatusCompleted, true},
        {"in_progress to in_review", StatusInProgress, StatusInReview, false},
        {"in_review to completed", StatusInReview, StatusCompleted, false},
        {"in_review back to in_progress", StatusInReview, StatusInProgress, false},
        {"completed to discharged", StatusCompleted, StatusDischarged, false},
        {"discharged to anything (invalid)", StatusDischarged, StatusTriaged, true},
        {"cancelled to anything (invalid)", 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
    }{
        {"valid vitals", 72, 120, 80, 36.6, 98, 3, nil},
        {"heart rate too low", 10, 120, 80, 36.6, 98, 0, shared.ErrInvalidHeartRate},
        {"heart rate too high", 350, 120, 80, 36.6, 98, 0, shared.ErrInvalidHeartRate},
        {"systolic too low", 72, 40, 80, 36.6, 98, 0, shared.ErrInvalidBloodPressure},
        {"temperature too low", 72, 120, 80, 25.0, 98, 0, shared.ErrInvalidTemperature},
        {"oxygen sat too low", 72, 120, 80, 36.6, -1, 0, shared.ErrInvalidOxygenSaturation},
        {"pain level too high", 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
    }{
        {"normal vitals", Vitals{HeartRateBPM: 72, SystolicBP: 120, OxygenSaturation: 98, TemperatureCelsius: 36.6}, false},
        {"low heart rate", Vitals{HeartRateBPM: 35, SystolicBP: 120, OxygenSaturation: 98, TemperatureCelsius: 36.6}, true},
        {"high heart rate", Vitals{HeartRateBPM: 190, SystolicBP: 120, OxygenSaturation: 98, TemperatureCelsius: 36.6}, true},
        {"low oxygen", Vitals{HeartRateBPM: 72, SystolicBP: 120, OxygenSaturation: 85, TemperatureCelsius: 36.6}, true},
        {"high fever", Vitals{HeartRateBPM: 72, SystolicBP: 120, OxygenSaturation: 98, TemperatureCelsius: 41.0}, true},
        {"hypothermia", 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())
        })
    }
}

func TestRecordVitalsOnClosedEncounter(t *testing.T) {
    enc := &Encounter{Status: StatusDischarged}
    vitals := &Vitals{HeartRateBPM: 72}

    err := enc.RecordVitals(vitals)
    assert.ErrorIs(t, err, shared.ErrEncounterClosed)
}

Part 10: BDD with Gherkin

Patient Registration Feature

# test/bdd/features/patient_registration.feature
Feature: Patient Registration
  As a nurse at the reception desk
  I want to register new patients
  So that they can receive medical care

  Background:
    Given the clinical system is running
    And the database is clean

  Scenario: Register a new patient with complete information
    When I register a patient with:
      | first_name    | Maria              |
      | last_name     | Garcia             |
      | date_of_birth | 1990-06-15         |
      | gender        | female             |
      | email         | maria@example.com  |
      | phone         | +52-686-555-0100   |
      | blood_type    | O+                 |
    Then the patient should be registered
    And the patient should have a medical record number
    And the patient should be active

  Scenario: Cannot register a patient without a name
    When I register a patient with:
      | first_name    |                    |
      | last_name     | Garcia             |
      | date_of_birth | 1990-06-15         |
      | gender        | female             |
    Then I should see an error "first name is required"

  Scenario: Search patients by name
    Given a patient "Carlos" "Hernandez" is registered
    And a patient "Carlos" "Martinez" is registered
    And a patient "Ana" "Lopez" is registered
    When I search for patients with "Carlos"
    Then I should find 2 patients

Clinical Encounter Feature

# test/bdd/features/clinical_encounter.feature
Feature: Clinical Encounter
  As a doctor
  I want to manage clinical encounters
  So that I can track patient visits and medical history

  Background:
    Given the clinical system is running
    And a patient "Elena" "Ramirez" is registered
    And a provider "Dr. Rodriguez" exists with role "physician"

  Scenario: Start a new encounter
    When I start an encounter for patient "Elena Ramirez" with:
      | chief_complaint | Persistent headache for 3 days |
    Then the encounter should be created
    And the encounter status should be "triaged"

  Scenario: Record vital signs
    Given an encounter exists for patient "Elena Ramirez"
    When I record vitals:
      | heart_rate   | 78    |
      | systolic_bp  | 130   |
      | diastolic_bp | 85    |
      | temperature  | 37.2  |
      | resp_rate    | 16    |
      | oxygen_sat   | 97    |
      | pain_level   | 6     |
    Then the vitals should be recorded
    And the vitals should not be critical

  Scenario: Record critical vital signs triggers warning
    Given an encounter exists for patient "Elena Ramirez"
    When I record vitals:
      | heart_rate   | 42    |
      | systolic_bp  | 200   |
      | diastolic_bp | 110   |
      | temperature  | 40.5  |
      | resp_rate    | 28    |
      | oxygen_sat   | 85    |
      | pain_level   | 9     |
    Then the vitals should be recorded
    And the vitals should be critical

  Scenario: Add a diagnosis with ICD-10 code
    Given an encounter exists for patient "Elena Ramirez"
    When I add a diagnosis:
      | icd_code    | G43.909                       |
      | description | Migraine, unspecified          |
      | severity    | moderate                       |
      | is_primary  | true                           |
    Then the diagnosis should be recorded

  Scenario: Write a clinical note
    Given an encounter exists for patient "Elena Ramirez"
    When I write a clinical note:
      | type    | progress                                              |
      | content | Patient presents with bilateral headache, onset 3 days ago. No visual disturbances. No nausea. Previous history of migraines since age 20. Recommending prophylactic treatment. |
    Then the note should be recorded

  Scenario: View full patient history
    Given an encounter exists for patient "Elena Ramirez" with vitals and notes
    And a second encounter exists for patient "Elena Ramirez"
    When I request the history for patient "Elena Ramirez"
    Then I should see 2 encounters
    And the encounters should be ordered by date descending

  Scenario: Cannot modify a discharged encounter
    Given an encounter exists for patient "Elena Ramirez" with status "discharged"
    When I try to record vitals for the encounter
    Then I should see an error "encounter is closed, no further modifications allowed"

  Scenario: Encounter status workflow
    Given an encounter exists for patient "Elena Ramirez"
    When I transition the encounter to "in_progress"
    Then the encounter status should be "in_progress"
    When I transition the encounter to "in_review"
    Then the encounter status should be "in_review"
    When I transition the encounter to "completed"
    Then the encounter status should be "completed"
    When I transition the encounter to "discharged"
    Then the encounter status should be "discharged"

Board Workflow Feature

# test/bdd/features/board_workflow.feature
Feature: Clinical Board Workflow
  As a charge nurse
  I want a visual board of active encounters
  So that I can manage patient flow through the department

  Background:
    Given the clinical system is running
    And a clinical board "Emergency Department" exists

  Scenario: Board has default workflow columns
    When I view the board "Emergency Department"
    Then I should see columns:
      | Triage      |
      | In Progress |
      | Review      |
      | Completed   |
      | Discharged  |

  Scenario: Add a patient to the board
    Given a patient "Luis" "Mendez" has an active encounter
    When I add the encounter to the board in column "Triage"
    Then the card should appear in "Triage"
    And the card should show "Luis Mendez"

  Scenario: Move a card across columns
    Given a card for "Luis Mendez" exists in "Triage"
    When I move the card to "In Progress"
    Then the card should appear in "In Progress"
    And "Triage" should have 0 cards

  Scenario: WIP limit prevents overloading
    Given the column "In Progress" has a WIP limit of 3
    And 3 cards already exist in "In Progress"
    When I try to move a card to "In Progress"
    Then I should see an error "column has reached its WIP limit"

  Scenario: Critical patients are highlighted
    Given a card for "Luis Mendez" exists in "Triage" with priority "critical"
    When I view the board
    Then the card for "Luis Mendez" should have priority "critical"

Part 11: Docker Compose and 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"
    volumes:
      - pgdata:/var/lib/postgresql/data
    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

volumes:
  pgdata:
# Makefile
.PHONY: dev test test-bdd build lint migrate 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

lint:
	golangci-lint run ./...

migrate-up:
	migrate -path migrations -database "$$DATABASE_URL" up

migrate-down:
	migrate -path migrations -database "$$DATABASE_URL" down 1

docker-up:
	docker compose up -d

docker-down:
	docker compose down -v

docker-logs:
	docker compose logs -f api
# Dockerfile
FROM golang:1.25-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -o /api ./cmd/api

FROM alpine:3.21
RUN apk --no-cache add ca-certificates tzdata
COPY --from=builder /api /api
EXPOSE 8080
CMD ["/api"]

The Architecture Visualized

graph TD
    subgraph "HTTP Layer"
        R[Chi Router]
        PH[Patient Handler]
        EH[Encounter Handler]
        BH[Board Handler]
    end

    subgraph "Application Layer"
        RP[Register Patient]
        SE[Start Encounter]
        RV[Record Vitals]
        AD[Add Diagnosis]
        WN[Write Note]
        GH[Get History]
        MC[Move Card]
    end

    subgraph "Domain Layer"
        PAT[Patient Entity]
        ENC[Encounter Aggregate]
        VIT[Vitals Value Object]
        DIA[Diagnosis Value Object]
        NOTE[Clinical Note]
        BRD[Board Aggregate]
        COL[Column]
        CARD[Card]
    end

    subgraph "Infrastructure"
        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 & COL & CARD
    PAT -.-> PG
    ENC -.-> PG
    BRD -.-> PG
    PAT -.-> RD

Why a Medical Domain Teaches Better Engineering

A medical system exposes patterns that simpler domains hide:

Append-only data — Vitals and notes are never edited. You record corrections as addendums. This forces you to design immutable value objects and temporal queries properly.

State machines — An encounter moves through triage, in-progress, review, completed, discharged. The domain enforces valid transitions. You cannot skip steps. You cannot go backwards from discharged.

Aggregate boundaries — A patient owns their encounters. An encounter owns its vitals, diagnoses, and notes. The aggregate root enforces invariants. You cannot add vitals to a closed encounter because the encounter checks its own state.

WIP limits — The board enforces capacity constraints. A column with a WIP limit of 5 rejects the 6th card. This is a real operational constraint in emergency departments.

Critical detection — The vitals domain knows what constitutes a medical emergency. When oxygen saturation drops below 90%, the system logs a warning. Business rules encoded in the domain, not in a database trigger.

Audit trails — Every entity has CreatedAt and UpdatedAt. Clinical notes have WrittenAt and optional AddendumTo. The system knows who did what and when.

These are not academic exercises. They are the same patterns that make financial systems, logistics platforms, and government services reliable. A medical domain just makes the consequences visceral — you cannot afford to get it wrong when a patient’s life depends on the data.

Software architecture is not about elegance. It is about encoding the rules of the world your software lives in. A medical system forces you to encode rules that matter: immutability, valid transitions, capacity limits, critical thresholds. If your architecture cannot express these constraints, it will fail exactly when it matters most.

Tags

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