Engineering Notes

Engineering Notes

Fhir: https://hl7.org/fhir/procedure.html

 

https://talk.openmrs.org/t/procedures-app-architecture-questions/47948/7

 

Jan 21, 2026

Questions

  1. When updating existing, do we void and insert or update existing

Dedicated Table Approach

Create a procedure recording system using a dedicated database table instead of Obs groups. This approach offers simpler querying, better performance, and explicit schema.

Requirements Summary (Same as Before)

Core Fields

  • uuid (auto-generated)

  • procedure (coded or free text) - REQUIRED

  • bodySite (Concept) - REQUIRED

  • startDateTime (Date) - REQUIRED (used for sorting)

  • originalDateText (String, optional - presence indicates historical procedure)

  • endDateTime (Date, optional)

  • duration (Integer, optional)

  • durationUnit (enum: SECONDS, MINUTES, HOURS, DAYS, optional)

  • encounter (Encounter, optional)

  • outcome (coded or free text, optional)

  • notes (String, optional)

  • formNamespace/formFieldPath (for FormRecordable support)

Two Recording Contexts (Validation-time only)

  1. Historical: Has originalDateText (e.g., "around 2020 Feb")

  2. Current: No originalDateText, precise dates


Implementation Steps

Phase 1: Database Schema (Liquibase)

File to create: api/src/main/resources/liquibase.xml (or add changeset if exists)

CREATE TABLE emrapi_procedure ( procedure_id INT AUTO_INCREMENT PRIMARY KEY, patient_id INT NOT NULL, -- FK to patient encounter_id INT, -- FK to encounter (optional) -- Procedure name (coded or free text) procedure_coded INT, -- FK to concept (coded procedure) procedure_non_coded VARCHAR(255), -- Free text procedure name -- Body site (required, coded only) body_site_id INT NOT NULL, -- FK to concept -- Timing start_date_time DATETIME NOT NULL, -- When procedure started original_date_text VARCHAR(255), -- "around 2020 Feb" (indicates historical) end_date_time DATETIME, -- When procedure ended duration INT, -- Duration value duration_unit VARCHAR(20), -- SECONDS, MINUTES, HOURS, DAYS -- Outcome (coded or free text) outcome_coded INT, -- FK to concept outcome_non_coded VARCHAR(255), -- Free text outcome -- Notes notes TEXT, -- FormRecordable support form_namespace VARCHAR(255), form_field_path VARCHAR(255), -- Audit fields (from BaseOpenmrsData) uuid VARCHAR(38) NOT NULL UNIQUE, creator INT NOT NULL, date_created DATETIME NOT NULL, changed_by INT, date_changed DATETIME, voided BOOLEAN DEFAULT FALSE, voided_by INT, date_voided DATETIME, void_reason VARCHAR(255), -- Foreign keys CONSTRAINT fk_procedure_patient FOREIGN KEY (patient_id) REFERENCES patient(patient_id), CONSTRAINT fk_procedure_encounter FOREIGN KEY (encounter_id) REFERENCES encounter(encounter_id), CONSTRAINT fk_procedure_coded FOREIGN KEY (procedure_coded) REFERENCES concept(concept_id), CONSTRAINT fk_procedure_body_site FOREIGN KEY (body_site_id) REFERENCES concept(concept_id), CONSTRAINT fk_procedure_outcome FOREIGN KEY (outcome_coded) REFERENCES concept(concept_id), CONSTRAINT fk_procedure_creator FOREIGN KEY (creator) REFERENCES users(user_id), CONSTRAINT fk_procedure_changed_by FOREIGN KEY (changed_by) REFERENCES users(user_id), CONSTRAINT fk_procedure_voided_by FOREIGN KEY (voided_by) REFERENCES users(user_id) ); -- Index for common queries CREATE INDEX idx_procedure_patient ON emrapi_procedure(patient_id); CREATE INDEX idx_procedure_start_date ON emrapi_procedure(start_date_time); CREATE INDEX idx_procedure_encounter ON emrapi_procedure(encounter_id);

Phase 2: Domain Model (Entity Class)

File to create: api/src/main/java/org/openmrs/module/emrapi/procedure/Procedure.java

@Entity @Table(name = "emrapi_procedure") public class Procedure extends BaseChangeableOpenmrsData implements FormRecordable { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "procedure_id") private Integer procedureId; @ManyToOne @JoinColumn(name = "patient_id", nullable = false) private Patient patient; @ManyToOne @JoinColumn(name = "encounter_id") private Encounter encounter; // Procedure name - coded or free text @ManyToOne @JoinColumn(name = "procedure_coded") private Concept procedureCoded; @Column(name = "procedure_non_coded", length = 255) private String procedureNonCoded; // Body site (required) @ManyToOne @JoinColumn(name = "body_site_id", nullable = false) private Concept bodySite; // Timing @Column(name = "start_date_time", nullable = false) private Date startDateTime; @Column(name = "original_date_text", length = 255) private String originalDateText; // Indicates historical if present @Column(name = "end_date_time") private Date endDateTime; @Column(name = "duration") private Integer duration; @Enumerated(EnumType.STRING) @Column(name = "duration_unit", length = 20) private DurationUnit durationUnit; // Outcome - coded or free text @ManyToOne @JoinColumn(name = "outcome_coded") private Concept outcomeCoded; @Column(name = "outcome_non_coded", length = 255) private String outcomeNonCoded; // Notes @Column(name = "notes", columnDefinition = "TEXT") private String notes; // FormRecordable @Column(name = "form_namespace", length = 255) private String formNamespace; @Column(name = "form_field_path", length = 255) private String formFieldPath; // Enum for duration units public enum DurationUnit { SECONDS, MINUTES, HOURS, DAYS } // FormRecordable implementation @Override public String getFormFieldNamespace() { return formNamespace; } @Override public String getFormFieldPath() { return formFieldPath; } @Override public void setFormField(String namespace, String path) { this.formNamespace = namespace; this.formFieldPath = path; } // Helper method public boolean isHistorical() { return originalDateText != null && !originalDateText.isEmpty(); } @Override public Integer getId() { return procedureId; } @Override public void setId(Integer id) { this.procedureId = id; } // Getters and setters... }

Key points:

  • Extends BaseChangeableOpenmrsData for automatic audit trail (creator, dateCreated, changedBy, voided, etc.)

  • Implements FormRecordable for form field tracking

  • Uses JPA annotations for Hibernate mapping

  • @Enumerated(EnumType.STRING) stores enum as readable string


Phase 3: DAO Layer

File to create: api/src/main/java/org/openmrs/module/emrapi/procedure/ProcedureDAO.java

public interface ProcedureDAO { Procedure getById(Integer id); Procedure getByUuid(String uuid); Procedure saveOrUpdate(Procedure procedure); List<Procedure> getProceduresByPatient(Patient patient, boolean includeVoided); List<Procedure> getProceduresByEncounter(Encounter encounter); List<Procedure> getHistoricalProcedures(Patient patient); // originalDateText not null }

File to create: api/src/main/java/org/openmrs/module/emrapi/procedure/HibernateProcedureDAO.java

public class HibernateProcedureDAO implements ProcedureDAO { private DbSessionFactory sessionFactory; public void setSessionFactory(DbSessionFactory sessionFactory) { this.sessionFactory = sessionFactory; } @Override public Procedure getByUuid(String uuid) { return (Procedure) sessionFactory.getCurrentSession() .createQuery("from Procedure p where p.uuid = :uuid") .setParameter("uuid", uuid) .uniqueResult(); } @Override public List<Procedure> getProceduresByPatient(Patient patient, boolean includeVoided) { String hql = "from Procedure p where p.patient = :patient"; if (!includeVoided) { hql += " and p.voided = false"; } hql += " order by p.startDateTime desc"; return sessionFactory.getCurrentSession() .createQuery(hql) .setParameter("patient", patient) .list(); } @Override public List<Procedure> getHistoricalProcedures(Patient patient) { return sessionFactory.getCurrentSession() .createQuery("from Procedure p where p.patient = :patient " + "and p.originalDateText is not null and p.voided = false " + "order by p.startDateTime desc") .setParameter("patient", patient) .list(); } // ... other methods }

Phase 4: Service Layer

File to create: api/src/main/java/org/openmrs/module/emrapi/procedure/ProcedureService.java

public interface ProcedureService extends OpenmrsService { Procedure saveProcedure(Procedure procedure) throws APIException; Procedure getProcedureByUuid(String uuid); List<Procedure> getProceduresByPatient(Patient patient); List<Procedure> getProceduresByEncounter(Encounter encounter); List<Procedure> getHistoricalProcedures(Patient patient); Procedure voidProcedure(Procedure procedure, String voidReason); }

File to create: api/src/main/java/org/openmrs/module/emrapi/procedure/ProcedureServiceImpl.java

public class ProcedureServiceImpl extends BaseOpenmrsService implements ProcedureService { private ProcedureDAO procedureDAO; public void setProcedureDAO(ProcedureDAO procedureDAO) { this.procedureDAO = procedureDAO; } @Override @Transactional public Procedure saveProcedure(Procedure procedure) throws APIException { validateProcedure(procedure); return procedureDAO.saveOrUpdate(procedure); } private void validateProcedure(Procedure procedure) { if (procedure.getPatient() == null) { throw new APIException("Patient is required"); } if (procedure.getProcedureCoded() == null && StringUtils.isBlank(procedure.getProcedureNonCoded())) { throw new APIException("Procedure (coded or free text) is required"); } if (procedure.getBodySite() == null) { throw new APIException("Body site is required"); } if (procedure.getStartDateTime() == null) { throw new APIException("Start date time is required"); } if (procedure.getDuration() != null && procedure.getDurationUnit() == null) { throw new APIException("Duration unit is required when duration is specified"); } } @Override @Transactional(readOnly = true) public List<Procedure> getProceduresByPatient(Patient patient) { return procedureDAO.getProceduresByPatient(patient, false); } // ... other methods }

Phase 5: Hibernate Mapping Registration

File to modify: api/src/main/resources/moduleApplicationContext.xml

Add the entity to Hibernate session factory mappings:

<!-- DAO Bean --> <bean id="procedureDAO" class="org.openmrs.module.emrapi.procedure.HibernateProcedureDAO"> <property name="sessionFactory" ref="dbSessionFactory"/> </bean> <!-- Service Bean with Transaction Proxy --> <bean id="procedureService" class="org.springframework.transaction.interceptor.TransactionProxyFactoryBean"> <property name="transactionManager" ref="transactionManager"/> <property name="target"> <bean class="org.openmrs.module.emrapi.procedure.ProcedureServiceImpl"> <property name="procedureDAO" ref="procedureDAO"/> </bean> </property> <property name="preInterceptors" ref="serviceInterceptors"/> <property name="transactionAttributeSource" ref="transactionAttributeSource"/> </bean> <!-- Register with Service Context --> <bean parent="serviceContext"> <property name="moduleService"> <list merge="true"> <value>org.openmrs.module.emrapi.procedure.ProcedureService</value> <ref bean="procedureService"/> </list> </property> </bean>

File to create/modify: api/src/main/resources/Procedure.hbm.xml (if not using annotations)

Or ensure entity scanning includes the package in config.xml:

<mappingFiles> org/openmrs/module/emrapi/procedure/Procedure.hbm.xml </mappingFiles>

Phase 6: REST Controller

File to create: omod/src/main/java/org/openmrs/module/emrapi/web/controller/ProcedureController.java

@Controller @RequestMapping("/rest/emrapi/procedure") public class ProcedureController extends BaseRestController { @Autowired private ProcedureService procedureService; @Autowired private PatientService patientService; @Autowired private ConceptService conceptService; // GET /rest/emrapi/procedure?patient={uuid} @RequestMapping(method = RequestMethod.GET) @ResponseBody public List<ProcedureDTO> getProcedures( @RequestParam("patient") String patientUuid, @RequestParam(value = "historical", required = false) Boolean historical) { Patient patient = patientService.getPatientByUuid(patientUuid); List<Procedure> procedures; if (Boolean.TRUE.equals(historical)) { procedures = procedureService.getHistoricalProcedures(patient); } else { procedures = procedureService.getProceduresByPatient(patient); } return procedures.stream() .map(this::toDTO) .collect(Collectors.toList()); } // GET /rest/emrapi/procedure/{uuid} @RequestMapping(value = "/{uuid}", method = RequestMethod.GET) @ResponseBody public ProcedureDTO getProcedure(@PathVariable("uuid") String uuid) { Procedure procedure = procedureService.getProcedureByUuid(uuid); if (procedure == null) { throw new ObjectNotFoundException(); } return toDTO(procedure); } // POST /rest/emrapi/procedure/historical @RequestMapping(value = "/historical", method = RequestMethod.POST) @ResponseBody public ProcedureDTO createHistoricalProcedure(@RequestBody ProcedureDTO dto) { if (StringUtils.isBlank(dto.getOriginalDateText())) { throw new IllegalArgumentException("originalDateText is required for historical procedures"); } Procedure procedure = fromDTO(dto); procedure = procedureService.saveProcedure(procedure); return toDTO(procedure); } // POST /rest/emrapi/procedure/current @RequestMapping(value = "/current", method = RequestMethod.POST) @ResponseBody public ProcedureDTO createCurrentProcedure(@RequestBody ProcedureDTO dto) { // Ignore originalDateText for current procedures dto.setOriginalDateText(null); Procedure procedure = fromDTO(dto); procedure = procedureService.saveProcedure(procedure); return toDTO(procedure); } // DELETE /rest/emrapi/procedure/{uuid} @RequestMapping(value = "/{uuid}", method = RequestMethod.DELETE) @ResponseBody public void voidProcedure( @PathVariable("uuid") String uuid, @RequestParam("reason") String reason) { Procedure procedure = procedureService.getProcedureByUuid(uuid); procedureService.voidProcedure(procedure, reason); } }

Phase 7: DTO Class

File to create: omod/src/main/java/org/openmrs/module/emrapi/web/controller/ProcedureDTO.java

public class ProcedureDTO { private String uuid; private String patientUuid; private String encounterUuid; // Procedure private String codedProcedureUuid; private String freeTextProcedure; // Body site private String bodySiteUuid; // Timing private Date startDateTime; private String originalDateText; private Date endDateTime; private Integer duration; private String durationUnit; // "SECONDS", "MINUTES", "HOURS", "DAYS" // Outcome private String codedOutcomeUuid; private String freeTextOutcome; private String notes; // FormRecordable private String formNamespace; private String formFieldPath; // Audit private Date dateCreated; private boolean voided; // Getters and setters... }

Files Summary

Files to Create (7 files)

File

Purpose

File

Purpose

api/src/main/resources/liquibase.xml

Database table creation changeset

api/src/main/java/.../procedure/Procedure.java

Entity class extending BaseChangeableOpenmrsData

api/src/main/java/.../procedure/ProcedureDAO.java

DAO interface

api/src/main/java/.../procedure/HibernateProcedureDAO.java

DAO implementation

api/src/main/java/.../procedure/ProcedureService.java

Service interface

api/src/main/java/.../procedure/ProcedureServiceImpl.java

Service implementation

omod/src/main/java/.../web/controller/ProcedureController.java

REST endpoints

omod/src/main/java/.../web/controller/ProcedureDTO.java

Data transfer object

Files to Modify (2 files)

File

Changes

File

Changes

api/src/main/resources/moduleApplicationContext.xml

Add DAO and Service beans

omod/src/main/resources/config.xml

Add mapping file reference (if needed)


Implementation Sequence

  1. Phase 1: Schema - Create Liquibase changeset for emrapi_procedure table

  2. Phase 2: Entity - Create Procedure.java with JPA annotations

  3. Phase 3: DAO - Create DAO interface and Hibernate implementation

  4. Phase 4: Service - Create Service interface and implementation with validation

  5. Phase 5: Spring Config - Wire beans in moduleApplicationContext.xml

  6. Phase 6: REST - Create controller with GET/POST/DELETE endpoints

  7. Phase 7: Tests - Unit tests for DAO, Service, Controller


Verification Steps

  1. Table Creation: Run module, verify emrapi_procedure table exists with correct columns

  2. Historical Procedure: POST to /rest/emrapi/procedure/historical with originalDateText

  3. Current Procedure: POST to /rest/emrapi/procedure/current without originalDateText

  4. Validation: Verify required fields enforced (procedure, bodySite, startDateTime)

  5. Retrieval: GET procedures by patient, verify sorted by startDateTime

  6. Historical Filter: GET with ?historical=true returns only historical procedures

  7. FormRecordable: Verify formNamespace/formFieldPath stored and retrieved

  8. Audit Trail: Verify creator, dateCreated populated automatically

  9. Voiding: DELETE procedure, verify voided=true with reason


User Preferences (Confirmed)

  • Hibernate Mapping: JPA Annotations (modern, cleaner - annotations in Java class)

  • DTO Location: omod module (close to REST controller)


Key Advantages of This Approach

  1. Simple Queries: SELECT * FROM emrapi_procedure WHERE patient_id = ?

  2. Better Performance: No Obs tree traversal, direct column access

  3. Explicit Schema: Columns clearly defined, easy to understand

  4. Database Constraints: NOT NULL, foreign keys enforced at DB level

  5. Easy Indexing: Can add indexes on any column for performance

  6. Automatic Audit: BaseChangeableOpenmrsData handles creator, dateCreated, voided, etc.

  7. FormRecordable: Supported via dedicated columns

Trade-offs

  1. Schema Migration: Need Liquibase changeset (one-time effort)

  2. Breaks Convention: OpenMRS typically uses Obs for clinical data

  3. Less Flexible: Schema changes need migrations (vs adding concepts)

Jan 20, 2026

  • Database table for storing procedures

  • Domain model implementing FormRecordable

  • REST endpoints for CRUD operations (GET all, GET by ID, POST)

  • Support for both historical and current procedure recordings

Requirements Summary

Core Fields

  • uuid

  • procedure (CodedOrFreeTextAnswer - coded or free text procedure name) - REQUIRED

  • startDateTime (Date - when procedure started, REQUIRED for both types, used for sorting)

  • originalDateText (String - free text like "around 2020 Feb", "earlier 2019", optional)

    • Presence indicates historical procedure

    • Displayed in UI next to startDateTime to show it's relative

  • endDateTime (Date - when procedure ended, optional)

  • duration (Integer, optional)

  • durationUnit (enum: SECONDS, MINUTES, HOURS, DAYS, optional)

  • encounter (Encounter reference, optional)

  • outcome (CodedOrFreeTextAnswer - coded or free text outcome, optional)

  • notes (String - free text notes, optional)

  • bodySite (Concept - coded body site, REQUIRED)

Note: No procedureType stored - validation context (historical vs current) handled at form/API level only

Two Recording Contexts (Not Stored, UI/Validation Only)

  1. Historical Procedure Form: Patient-reported past procedures

    • Example: startDateTime=2020-02-01, originalDateText="around 2020 Feb"

    • Optional: endDateTime, duration, durationUnit, encounter

    • startDateTime + originalDateText displayed together in UI

  2. Current Procedure Form: Fully documented procedures

    • Precise startDateTime (no originalDateText)

    • May require encounter, endDateTime, duration based on form design

    • More stringent validation at form level

Storage: Both stored the same way - presence of originalDateText indicates historical

Technical Requirements

  • Implement FormRecordable interface

  • Link to specific patient

  • REST endpoints: GET all, GET by ID, POST

  • Body site always required

Naming Conventions

Following established patterns from Diagnosis, DrugOrder, and Observation classes:

  1. Java Fields: camelCase

    • Examples: diagnosisDateTime, freeTextAnswer, codedAnswer, voidReason, formFieldPath, durationUnit

    • NOT: diagnosis_date_time, free_text_answer, duration_unit

  2. Concept Code Constants: SCREAMING_SNAKE_CASE with Title Case string values

    • Examples: CONCEPT_CODE_DIAGNOSIS_ORDER_PRIMARY = "Primary"

    • NOT: CONCEPT_CODE_DIAGNOSIS_ORDER_PRIMARY = "PRIMARY" or "primary"

  3. Concept Names (in concept dictionary): Title Case with Spaces

    • Examples: "Diagnosis Concept Set", "Procedure Start Date Time", "Coded Procedure"

    • NOT: "diagnosis_concept_set", "ProcedureStartDateTime", "CODED_PROCEDURE"

  4. Java Class Names: PascalCase

    • Examples: ProcedureMapper, EncounterTransaction, CodedOrFreeTextAnswer

  5. Enum Values: ALL_CAPS

    • Examples: ProcedureType.HISTORICAL, DurationUnit.SECONDS

Phase 1: Initial Exploration

Explored the following areas:

  1. FormRecordable: Interface from OpenMRS core for tracking form field origins

  2. Domain Model Patterns: Diagnosis and Disposition models provide excellent patterns

  3. REST Endpoints: DelegatingCrudResource and BaseRestController patterns

Key Findings

  • Diagnosis model (/api/src/main/java/org/openmrs/module/emrapi/diagnosis/) is the best reference

  • Uses ConceptSetDescriptor pattern with Obs groups for storage

  • CodedOrFreeTextAnswer pattern for flexible coded/free-text fields

  • REST resources use DelegatingCrudResource for CRUD operations

  • Mappers convert between domain and EncounterTransaction DTOs

Phase 2: Clarifying Questions

Decisions:

  1. No procedureType stored: Type is validation context only (handled by UI forms)

    • Presence of originalDateText field indicates historical procedure

    • No need to track type after creation

  2. startDateTime always required: Used for sorting and querying (both types)

    • Historical: startDateTime=2020-02-01 + originalDateText="around 2020 Feb"

    • Current: startDateTime=2024-01-15T10:30:00 (no originalDateText)

  3. Date Display: UI shows originalDateText next to startDateTime when present

    • User knows it's a relative/historical date

  4. Two POST Endpoints: Separate endpoints for cleaner validation

    • /rest/emrapi/procedure/historical - requires originalDateText, looser validation

    • /rest/emrapi/procedure/current - no originalDateText, may require more fields

  5. Storage Model: Same structure for all procedures

    • Optional fields: endDateTime, duration, durationUnit, encounter, outcome, notes, originalDateText

  6. Body Site: Coded only (required)

    • Must be selected from coded concept list

Phase 3: Design

Comprehensive implementation plan created by Plan agent covering:

  • Domain model with Procedure.java and ProcedureMetadata.java

  • Service layer with validation

  • API layer with mappers

  • REST endpoints using BaseRestController pattern

  • Testing strategy

Phase 4: Final Implementation Plan

Architecture Overview

How Obs Groups Work (Diagnosis Example)

This section explains the Obs groups pattern using Diagnosis as a concrete example, from concepts to database tables.

1. What Are "Concepts" in OpenMRS?

Concepts are OpenMRS's way of defining medical questions and answers. Think of them as a dictionary:

Concept ID | Name | Datatype | Class -----------|-------------------------|----------|------------- 1001 | "Diagnosis Concept Set" | N/A | ConvSet (container) 1002 | "Coded Diagnosis" | Coded | Question 1003 | "Non-Coded Diagnosis" | Text | Question 1004 | "Diagnosis Order" | Coded | Question 1005 | "Primary" | N/A | Answer 1006 | "Secondary" | N/A | Answer 1007 | "Diagnosis Certainty" | Coded | Question 1008 | "Confirmed" | N/A | Answer 1009 | "Presumed" | N/A | Answer

These are stored in the concept table and define what CAN be recorded.

2. What Are These Concepts in DiagnosisMetadata?

DiagnosisMetadata holds references to specific concepts needed for storing diagnoses:

public class DiagnosisMetadata extends ConceptSetDescriptor { private Concept diagnosisSetConcept; // Concept #1001 "Diagnosis Concept Set" private Concept codedDiagnosisConcept; // Concept #1002 "Coded Diagnosis" private Concept nonCodedDiagnosisConcept; // Concept #1003 "Non-Coded Diagnosis" private Concept diagnosisOrderConcept; // Concept #1004 "Diagnosis Order" private Concept diagnosisCertaintyConcept; // Concept #1007 "Diagnosis Certainty" }

Why needed? To build and parse Obs groups, the code needs to know which concept IDs represent which fields.

3. How Data Gets Stored - Concrete Example

Scenario: Doctor records "Patient has malaria (confirmed, primary diagnosis)"

Step 1 - Java Object (Domain Model):

Diagnosis diagnosis = new Diagnosis( new CodedOrFreeTextAnswer(malariaConceptId), // What: malaria Diagnosis.Order.PRIMARY, // Order: primary Diagnosis.Certainty.CONFIRMED // Certainty: confirmed );

Step 2 - Convert to Obs Group (DiagnosisMetadata.buildDiagnosisObsGroup()):

// Creates parent Obs Obs parentObs = new Obs(); parentObs.setConcept(diagnosisSetConcept); // concept_id = 1001 // Creates child Obs for each field Obs orderObs = new Obs(); orderObs.setConcept(diagnosisOrderConcept); // concept_id = 1004 (question) orderObs.setValueCoded(primaryConcept); // value_coded = 1005 (answer "Primary") Obs certaintyObs = new Obs(); certaintyObs.setConcept(diagnosisCertaintyConcept); // concept_id = 1007 certaintyObs.setValueCoded(confirmedConcept); // value_coded = 1008 Obs diagnosisObs = new Obs(); diagnosisObs.setConcept(codedDiagnosisConcept); // concept_id = 1002 diagnosisObs.setValueCoded(malariaConcept); // value_coded = 555 (malaria) // Link children to parent parentObs.addGroupMember(orderObs); parentObs.addGroupMember(certaintyObs); parentObs.addGroupMember(diagnosisObs);

Step 3 - Database Tables (After obsService.saveObs()):

obs table (simplified):

obs_id | obs_group_id | concept_id | value_coded | value_text | person_id | obs_datetime -------|--------------|------------|-------------|------------|-----------|------------- 101 | NULL | 1001 | NULL | NULL | 42 | 2024-01-15 -- PARENT 102 | 101 | 1004 | 1005 | NULL | 42 | 2024-01-15 -- Order = "Primary" 103 | 101 | 1007 | 1008 | NULL | 42 | 2024-01-15 -- Certainty = "Confirmed" 104 | 101 | 1002 | 555 | NULL | 42 | 2024-01-15 -- Diagnosis = "Malaria"

Key relationships:

  • Row 101: Parent Obs (concept_id = 1001 = "Diagnosis Concept Set")

  • Rows 102-104: Children (obs_group_id = 101 points to parent)

  • Each child has:

    • concept_id = the QUESTION ("What order?", "What certainty?", "What diagnosis?")

    • value_coded = the ANSWER concept ID ("Primary", "Confirmed", "Malaria")

4. Reading Data Back (DiagnosisMetadata.toDiagnosis())

When reading:

// Fetch obs with obs_id=101 Obs obsGroup = obsService.getObs(101); // Parse it back to domain object Diagnosis diagnosis = diagnosisMetadata.toDiagnosis(obsGroup); // Code finds child Obs by concept_id: Obs orderObs = findMember(obsGroup, diagnosisOrderConcept); // Finds row 102 Obs certaintyObs = findMember(obsGroup, diagnosisCertaintyConcept); // Finds row 103 Obs diagnosisObs = findMember(obsGroup, codedDiagnosisConcept); // Finds row 104 // Extract values Diagnosis.Order order = parseOrder(orderObs.getValueCoded()); // "Primary" Diagnosis.Certainty certainty = parseCertainty(certaintyObs.getValueCoded()); // "Confirmed" Concept diagnosisConcept = diagnosisObs.getValueCoded(); // Malaria concept

5. Why This Complexity?

Advantages:

  • Flexible: Can add new diagnosis fields without schema changes (just add concepts)

  • Standardized: All clinical data uses same obs table structure

  • Form Integration: Forms can reference concept IDs to bind to fields

Disadvantages:

  • Complex: Must understand concept dictionary, Obs groups, parent/child relationships

  • Query Complexity: Joining obs table to itself multiple times is slow

  • More Code: Need DiagnosisMetadata to build/parse Obs tree

6. Full Database Query Example

To find all PRIMARY diagnoses of MALARIA for patient 42:

SELECT parent.obs_id, parent.obs_datetime, diagnosis.value_coded as diagnosis_concept_id, orderObs.value_coded as order_concept_id, certaintyObs.value_coded as certainty_concept_id FROM obs parent -- Join to find diagnosis value INNER JOIN obs diagnosis ON diagnosis.obs_group_id = parent.obs_id AND diagnosis.concept_id = 1002 -- "Coded Diagnosis" AND diagnosis.value_coded = 555 -- "Malaria" -- Join to find order INNER JOIN obs orderObs ON orderObs.obs_group_id = parent.obs_id AND orderObs.concept_id = 1004 -- "Diagnosis Order" AND orderObs.value_coded = 1005 -- "Primary" -- Join to find certainty LEFT JOIN obs certaintyObs ON certaintyObs.obs_group_id = parent.obs_id AND certaintyObs.concept_id = 1007 -- "Diagnosis Certainty" WHERE parent.concept_id = 1001 -- "Diagnosis Concept Set" AND parent.person_id = 42 AND parent.voided = 0;

Notice: 4 table joins just to query one diagnosis! This is the complexity vs a flat table.

7. Procedure Would Work the Same Way

For Procedure with Obs groups:

  • Need ~15 concepts: "Procedure Concept Set", "Coded Procedure", "Procedure Start Date Time", etc.

  • ProcedureMetadata builds Obs tree with parent + children

  • Each procedure field = one child Obs row

  • Queries need multiple joins to obs table

Compare to dedicated table:

  • One table, one row per procedure

  • Simple SELECT with direct column access

  • No concept dictionary needed


Storage Approach Decision

Option A: Obs Groups (Current Plan - Diagnosis Pattern)

Pros:

  • ✅ Consistent with OpenMRS architecture (Diagnosis, Condition use this)

  • ✅ Automatic audit trail (creator, dateCreated, changedBy, voided, voidedBy)

  • ✅ FormRecordable support built-in (Obs implements FormRecordable)

  • ✅ No schema migrations needed (uses existing obs table)

  • ✅ Leverages existing Obs querying infrastructure

  • ✅ Can link to forms/encounters naturally

  • ✅ Follows established codebase patterns

Cons:

  • ❌ Complex querying (must traverse Obs tree with joins)

  • ❌ Performance overhead vs flat table

  • ❌ Requires ~15 concepts to be created

  • ❌ More complex data model (parent Obs + child Obs members)

  • ❌ Harder to write direct SQL queries

  • ❌ Can be harder for new developers to understand

Implementation:

  • Domain Model: Procedure.java

  • Metadata: ProcedureMetadata.java extends ConceptSetDescriptor

  • Storage: Obs groups with child members