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.
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
Related Articles
Organizational Health Through Architecture: Building Alignment, Trust & Healthy Culture
Learn how architecture decisions shape organizational culture, health, and alignment. Discover how to use architecture as a tool for building trust, preventing silos, enabling transparency, and creating sustainable organizational growth.
Team Health & Burnout Prevention: How Architecture Decisions Impact Human Well-being
Master the human side of architecture. Learn to recognize burnout signals, architect sustainable systems, build psychological safety, and protect team health. Because healthy teams build better systems.
Difficult Conversations & Conflict Resolution: Navigating Disagreement, Politics & Defensive Teams
Master the art of having difficult conversations as an architect. Learn how to manage technical disagreements, handle defensive teams, say no effectively, and navigate organizational politics without damaging relationships.