Engineering Notes
Fhir: https://hl7.org/fhir/procedure.html
https://talk.openmrs.org/t/procedures-app-architecture-questions/47948/7
Jan 21, 2026
Questions
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)
Historical: Has
originalDateText(e.g., "around 2020 Feb")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
BaseChangeableOpenmrsDatafor automatic audit trail (creator, dateCreated, changedBy, voided, etc.)Implements
FormRecordablefor form field trackingUses 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 |
|---|---|
| Database table creation changeset |
| Entity class extending BaseChangeableOpenmrsData |
| DAO interface |
| DAO implementation |
| Service interface |
| Service implementation |
| REST endpoints |
| Data transfer object |
Files to Modify (2 files)
File | Changes |
|---|---|
| Add DAO and Service beans |
| Add mapping file reference (if needed) |
Implementation Sequence
Phase 1: Schema - Create Liquibase changeset for
emrapi_proceduretablePhase 2: Entity - Create
Procedure.javawith JPA annotationsPhase 3: DAO - Create DAO interface and Hibernate implementation
Phase 4: Service - Create Service interface and implementation with validation
Phase 5: Spring Config - Wire beans in
moduleApplicationContext.xmlPhase 6: REST - Create controller with GET/POST/DELETE endpoints
Phase 7: Tests - Unit tests for DAO, Service, Controller
Verification Steps
Table Creation: Run module, verify
emrapi_proceduretable exists with correct columnsHistorical Procedure: POST to
/rest/emrapi/procedure/historicalwithoriginalDateTextCurrent Procedure: POST to
/rest/emrapi/procedure/currentwithoutoriginalDateTextValidation: Verify required fields enforced (procedure, bodySite, startDateTime)
Retrieval: GET procedures by patient, verify sorted by startDateTime
Historical Filter: GET with
?historical=truereturns only historical proceduresFormRecordable: Verify formNamespace/formFieldPath stored and retrieved
Audit Trail: Verify creator, dateCreated populated automatically
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
Simple Queries:
SELECT * FROM emrapi_procedure WHERE patient_id = ?Better Performance: No Obs tree traversal, direct column access
Explicit Schema: Columns clearly defined, easy to understand
Database Constraints: NOT NULL, foreign keys enforced at DB level
Easy Indexing: Can add indexes on any column for performance
Automatic Audit:
BaseChangeableOpenmrsDatahandles creator, dateCreated, voided, etc.FormRecordable: Supported via dedicated columns
Trade-offs
Schema Migration: Need Liquibase changeset (one-time effort)
Breaks Convention: OpenMRS typically uses Obs for clinical data
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)
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
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:
Java Fields: camelCase
Examples:
diagnosisDateTime,freeTextAnswer,codedAnswer,voidReason,formFieldPath,durationUnitNOT:
diagnosis_date_time,free_text_answer,duration_unit
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"
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"
Java Class Names: PascalCase
Examples:
ProcedureMapper,EncounterTransaction,CodedOrFreeTextAnswer
Enum Values: ALL_CAPS
Examples:
ProcedureType.HISTORICAL,DurationUnit.SECONDS
Phase 1: Initial Exploration
Explored the following areas:
FormRecordable: Interface from OpenMRS core for tracking form field origins
Domain Model Patterns: Diagnosis and Disposition models provide excellent patterns
REST Endpoints: DelegatingCrudResource and BaseRestController patterns
Key Findings
Diagnosis model (
/api/src/main/java/org/openmrs/module/emrapi/diagnosis/) is the best referenceUses 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:
No procedureType stored: Type is validation context only (handled by UI forms)
Presence of
originalDateTextfield indicates historical procedureNo need to track type after creation
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)
Date Display: UI shows originalDateText next to startDateTime when present
User knows it's a relative/historical date
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
Storage Model: Same structure for all procedures
Optional fields: endDateTime, duration, durationUnit, encounter, outcome, notes, originalDateText
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 | AnswerThese 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 concept5. 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