Overview
The bahmni.appointment.forwarder plugin is an OpenMRS OMOD that intercepts Bahmni appointment events and forwards them to a central RabbitMQ broker. It uses Spring AOP to hook into the AppointmentsService without modifying any Bahmni code. Every time an appointment is created or rescheduled, the plugin publishes a JSON event to the appointment.exchange topic exchange.
Prerequisites
The following must be installed on the build machine:
| Software | Version | Purpose |
|---|---|---|
| Java JDK | 21 | Running OpenMRS, building the plugin requires JDK 8+ |
| Maven | 3.x | Building the plugin |
| Git | Any | Cloning repositories |
| Docker + Docker Compose | Any | Running OpenMRS locally |
The following must be built and installed to your local Maven repository before building the plugin:
openmrs-module-eventbuilt from master branch (installs asevent-api:4.1.0-SNAPSHOT)openmrs-module-appointmentsbuilt from tag2.1.0(installs asappointments-api:2.1.0)
Build the event module
git clone git@github.com:openmrs/openmrs-module-event.git
cd openmrs-module-event
mvn clean install -DskipTestsBuild the appointments module
git clone git@github.com:Bahmni/openmrs-module-appointments.git openmrs-module-appointments-2.1
cd openmrs-module-appointments-2.1
git checkout 2.1.0
mvn clean install -DskipTestsBuilding the Plugin
Clone the repository and build:
cd AvansModuleOpenMRS/bahmni.appointment.forwarder
mvn clean install -DskipTestsThe OMOD file is generated at:
omod/target/bahmni.appointment.forwarder-1.0.0-SNAPSHOT.omod
Deploying to OpenMRS
Step 1, Set up the modules folder
Create a modules folder next to your docker-compose.yml and copy the OMOD into it:
mkdir -p modules
cp omod/target/bahmni.appointment.forwarder-1.0.0-SNAPSHOT.omod modules/Step 2, Update docker-compose.yml
Add a bind mount under the backend service volumes section. The full volumes block should look like this:
volumes:
- openmrs-data:/openmrs/data
- ./modules/bahmni.appointment.forwarder-1.0.0-SNAPSHOT.omod:/openmrs/distribution/openmrs_modules/bahmni.appointment.forwarder-1.0.0-SNAPSHOT.omodThe bind mount points to the distribution folder, not the data folder. OpenMRS copies modules from the distribution folder into the data folder on every startup. Do not copy the OMOD directly into /openmrs/data/modules/ as it will be wiped on restart.
Step 3, Start OpenMRS
docker compose up -dWait approximately 3 minutes for OpenMRS to fully start. Monitor startup with:
docker logs -f $(docker ps --filter name=backend -q) 2>/dev/null \
| grep -i "done refreshing\|forwarder\|started"Once you see the following lines the plugin is loaded:
=== BAHMNI APPOINTMENT FORWARDER CONTEXT REFRESHED ===
=== BAHMNI APPOINTMENT FORWARDER STARTED ===
Configuring Global Properties
The plugin reads all its configuration from OpenMRS global properties. Set them directly in the database after the first startup:
docker exec $(docker ps --filter name=db -q) \
mysql -u openmrs -popenmrs openmrs -e "
INSERT INTO global_property (property, property_value, description, uuid) VALUES
('communicatie.tenantId', 'your-tenant-id', 'Tenant identifier', UUID()),
('communicatie.rabbitmq.host', 'your-host', 'RabbitMQ host', UUID()),
('communicatie.rabbitmq.port', '5672', 'RabbitMQ port', UUID()),
('communicatie.rabbitmq.username','your-username', 'RabbitMQ username', UUID()),
('communicatie.rabbitmq.password','your-password', 'RabbitMQ password', UUID())
ON DUPLICATE KEY UPDATE property_value=VALUES(property_value);" 2>/dev/nullReplace the placeholder values:
| Property | Example | Description |
|---|---|---|
communicatie.tenantId | hospital-amsterdam | Unique ID for this OpenMRS instance |
communicatie.rabbitmq.host | 86.48.5.113 | IP or hostname of the RabbitMQ server |
communicatie.rabbitmq.port | 5672 | AMQP port |
communicatie.rabbitmq.username | communicatie | RabbitMQ username |
communicatie.rabbitmq.password | yourpassword | RabbitMQ password |
Do not use a URI format for the RabbitMQ connection. The plugin uses individual ConnectionFactory setters to avoid URI encoding issues with special characters in passwords. |
Step 4, Restart to apply properties
docker compose restart backendWait for OpenMRS to start again. You should now see the RabbitMQ connection confirmed in the logs:
=== BAHMNI APPOINTMENT FORWARDER STARTED ===
=== CONNECTED TO RABBITMQ AT your-host:5672 ===
Verifying the Setup
Check the exchange exists
curl -s -u 'username:password' \
'http://your-rabbitmq-host:15672/api/exchanges/%2F/appointment.exchange'A JSON response describing the exchange confirms the plugin connected successfully. A 404 means the plugin has not connected yet.
Create a test queue binding
Before testing, create a queue bound to the exchange so messages are not dropped:
curl -s -u 'username:password' \
-X PUT 'http://your-rabbitmq-host:15672/api/queues/%2F/debug.queue' \
-H 'Content-Type: application/json' \
-d '{"durable":true,"auto_delete":false}' 2>/dev/null
curl -s -u 'username:password' \
-X POST 'http://your-rabbitmq-host:15672/api/bindings/%2F/e/appointment.exchange/q/debug.queue' \
-H 'Content-Type: application/json' \
-d '{"routing_key":"appointment.#"}' 2>/dev/nullCreate a test appointment
curl -s -u admin:Admin123 -X POST \
'http://localhost/openmrs/ws/rest/v1/appointment' \
-H 'Content-Type: application/json' \
-d '{
"patientUuid": "YOUR_PATIENT_UUID",
"serviceUuid": "YOUR_SERVICE_UUID",
"startDateTime": "2026-06-01T10:00:00.000Z",
"endDateTime": "2026-06-01T10:30:00.000Z",
"appointmentKind": "Scheduled",
"status": "Scheduled"
}' 2>/dev/nullRead the message from the queue
curl -s -u 'username:password' \
-X POST 'http://your-rabbitmq-host:15672/api/queues/%2F/debug.queue/get' \
-H 'Content-Type: application/json' \
-d '{"count":1,"ackmode":"ack_requeue_true","encoding":"auto"}' \
2>/dev/null | python3 -c "
import sys, json
data = json.load(sys.stdin)
if isinstance(data, list) and data:
payload = data[0].get('payload', '')
print(json.dumps(json.loads(payload), indent=2))
else:
print(json.dumps(data, indent=2))
"Message Format
Every appointment create or reschedule publishes the following JSON payload to appointment.exchange:
{
"tenantId": "hospital-amsterdam",
"eventType": "BAHMNI_APPOINTMENT_CREATED",
"timestamp": "2026-05-20T13:28:00Z",
"appointmentUuid": "ad37be8a-227d-4486-8862-513dafb9fe77",
"appointmentNumber": "0000",
"appointmentKind": "Scheduled",
"patientUuid": "c35a048b-c311-4f5d-8a21-322f469d16e3",
"patientName": "Richard Nelson",
"patientIdentifier": "10001LL",
"phoneNumber": "+31612345678",
"emailAddress": "richard.nelson@test.com",
"startDateTime": "2026-05-31T10:00:00Z",
"endDateTime": "2026-05-31T10:30:00Z",
"serviceName": "General Medicine",
"serviceUuid": "3bd2766b-538d-11f1-a6ad-a2e877b8b22b",
"status": "Scheduled",
"comments": null,
"voided": false
}Routing key format: appointment.{tenantId}.{eventType}
Event types:
BAHMNI_APPOINTMENT_CREATED, fired whenvalidateAndSaveis calledBAHMNI_APPOINTMENT_UPDATED, fired whenrescheduleAppointmentis called
Phone number and email are fetched from OpenMRS person attributes using the following attribute type UUIDs:
| Attribute | UUID |
|---|---|
| Telephone Number | 14d4f066-15f5-102d-96e4-000c29c2a5d7 |
| Email Address | c6b52b5c-5376-11f1-90b7-2a50222d1421 |
These UUIDs are hardcoded in AppointmentForwarderAdvice.java. If your OpenMRS instance uses different UUIDs for these attribute types, update the constants in that file and rebuild.
How It Works
The plugin uses Spring AOP AfterReturningAdvice registered on org.openmrs.module.appointments.service.AppointmentsService via the config.xml advice block. This means every time a method on that service returns successfully, our advice code runs automatically.
The advice:
- Checks if the intercepted method is
validateAndSaveorrescheduleAppointment - Reads the returned
Appointmentobject via reflection (direct class reference causes classloader issues) - Fetches patient phone number and email from OpenMRS via
Context.getPersonService() - Builds the JSON payload
- Publishes to
appointment.exchangewith the appropriate routing key TheRabbitMqPublisheris a static singleton that reconnects automatically if the channel drops.
Troubleshooting
Plugin not appearing in the module list
Check that the OMOD file is in the distribution folder inside the container:
docker exec $(docker ps --filter name=backend -q) \
ls /openmrs/distribution/openmrs_modules/ | grep forwarderIf it is missing, the bind mount in docker-compose.yml is not working. Verify the path is correct and restart with docker compose down && docker compose up -d.
Plugin appears but does not start
Check the logs for errors:
docker logs $(docker ps --filter name=backend -q) 2>/dev/null \
| grep -i "forwarder\|bahmni.appointment.forwarder" | tail -20Common causes:
- Old lib-cache conflict. Fix:
sudo rm -rf /var/lib/docker/volumes/.../_data/.openmrs-lib-cache/bahmni.appointment.forwarderand restart - Spring XML bean declarations in
moduleApplicationContext.xml. Fix: leave the file empty with only the root<beans>tag
RabbitMQ not connecting
Check that the global properties are set correctly:
docker exec $(docker ps --filter name=db -q) \
mysql -u openmrs -popenmrs openmrs \
-e "SELECT property, property_value FROM global_property WHERE property LIKE 'communicatie%';" 2>/dev/nullThen restart the backend so the plugin reads the updated values.
Events not being published
The AOP advice only intercepts validateAndSave and rescheduleAppointment on AppointmentsService. Appointments must be created through the standard Bahmni Appointments REST API at /openmrs/ws/rest/v1/appointment. Direct database inserts will not trigger the advice.
ClassNotFoundException on startup
The appointments-api or event-api jar bundled in the OMOD does not match the version installed in OpenMRS. Rebuild the dependencies locally from the correct tags and rebuild the plugin.
Old OMOD cached after redeployment
OpenMRS caches extracted OMOD contents in .openmrs-lib-cache. After replacing the OMOD file, clear the cache and restart:
sudo rm -rf /var/lib/docker/volumes/YOUR_VOLUME/_data/.openmrs-lib-cache/bahmni.appointment.forwarder
docker restart YOUR_BACKEND_CONTAINER