Model Events and Event Handlers
Overview
- What you’ll learn:
- The complete event system architecture in iDempiere, including all IEventTopics constants for persistence and document events
- How to implement event handlers using AbstractEventHandler with table-specific filtering, data access, and error handling
- How to compare old and new values, control event priority and ordering, and debug event handler issues in development
- Prerequisites: Lessons 1–21 (especially Lesson 21: Creating Your First Plugin)
- Estimated reading time: 24 minutes
Introduction
In the previous lesson, you created a plugin with a simple event handler that logged a message when a Business Partner was created. In real-world development, event handlers are the primary tool for implementing custom business logic in iDempiere. They enforce validation rules, auto-calculate field values, synchronize data between related records, trigger notifications, and integrate with external systems.
This lesson is a comprehensive guide to the model event system. We will examine every event topic, explore the full API available within event handlers, learn techniques for comparing old and new values, and build practical examples that demonstrate real business scenarios. By the end, you will be able to implement production-quality event-driven logic in your iDempiere plugins.
The Event System Architecture
iDempiere’s event system is built on top of the OSGi Event Admin service. When the core system performs an operation on a Persistent Object (PO) — such as saving, deleting, or processing a document — it fires events at specific lifecycle points. Plugins subscribe to these events and receive callbacks with full access to the affected data.
The architecture has three main components:
- Event Producer — The iDempiere core (specifically, the PO base class and the Document Engine) fires events at defined lifecycle points.
- Event Bus — The
IEventManagerservice (wrapping OSGi Event Admin) routes events to registered handlers based on topic matching. - Event Consumers — Your plugin’s event handlers, registered as OSGi services, receive and process the events.
Events are synchronous — the event producer waits for all handlers to complete before proceeding. This is important because it means your handler can prevent a save (by adding an error) or modify data (by changing PO field values) and the changes will take effect before the operation completes.
IEventTopics: The Complete Event Reference
The org.adempiere.base.event.IEventTopics interface defines constants for all available event topics. These fall into two categories: persistence events (fired during record save/delete) and document events (fired during document processing).
Persistence Events
These events fire when a record is saved to or deleted from the database:
| Constant | Topic String | When Fired |
|---|---|---|
PO_BEFORE_NEW |
org/adempiere/base/event/PO_BEFORE_NEW |
Before a new record is inserted into the database. The record has been validated but not yet committed. |
PO_AFTER_NEW |
org/adempiere/base/event/PO_AFTER_NEW |
After a new record has been successfully inserted. The database transaction has not yet been committed (you are still within the same transaction). |
PO_BEFORE_CHANGE |
org/adempiere/base/event/PO_BEFORE_CHANGE |
Before an existing record is updated. The PO contains the new values; original values are accessible via get_ValueOld(). |
PO_AFTER_CHANGE |
org/adempiere/base/event/PO_AFTER_CHANGE |
After an existing record has been successfully updated. |
PO_BEFORE_DELETE |
org/adempiere/base/event/PO_BEFORE_DELETE |
Before a record is deleted. You can prevent deletion by adding an error. |
PO_AFTER_DELETE |
org/adempiere/base/event/PO_AFTER_DELETE |
After a record has been deleted (but the transaction has not yet committed). |
Document Processing Events
These events fire during the document processing lifecycle (Prepare, Complete, Void, Close, Reverse, etc.):
| Constant | When Fired |
|---|---|
DOC_BEFORE_PREPARE |
Before a document enters the Prepare phase (validation before completion) |
DOC_AFTER_PREPARE |
After the Prepare phase completes successfully |
DOC_BEFORE_COMPLETE |
Before a document is completed (final processing) |
DOC_AFTER_COMPLETE |
After a document has been completed successfully |
DOC_BEFORE_VOID |
Before a document is voided |
DOC_AFTER_VOID |
After a document has been voided |
DOC_BEFORE_CLOSE |
Before a document is closed |
DOC_AFTER_CLOSE |
After a document has been closed |
DOC_BEFORE_REVERSECORRECT |
Before a document is reverse-corrected |
DOC_AFTER_REVERSECORRECT |
After a document has been reverse-corrected |
DOC_BEFORE_REVERSEACCRUAL |
Before a document is reverse-accrualed |
DOC_AFTER_REVERSEACCRUAL |
After a document has been reverse-accrualed |
DOC_BEFORE_REACTIVATE |
Before a document is reactivated from completed status |
DOC_AFTER_REACTIVATE |
After a document has been reactivated |
Choosing Between BEFORE and AFTER Events
The choice between BEFORE and AFTER events depends on what you need to do:
- Use BEFORE events when: You need to validate data and potentially prevent the operation (by adding an error), or you need to modify field values before they are saved to the database.
- Use AFTER events when: You need to perform follow-up actions that depend on the operation having succeeded (creating related records, sending notifications, updating counters), or you need access to the generated primary key ID for new records.
Implementing an Event Handler
The recommended base class for event handlers in iDempiere is org.adempiere.base.event.AbstractEventHandler. It provides convenience methods and handles the registration boilerplate.
The AbstractEventHandler API
public abstract class AbstractEventHandler implements EventHandler {
// Override this to register for specific events
protected abstract void initialize();
// Override this to handle events
protected abstract void doHandleEvent(Event event);
// Register for events on a specific table
protected void registerTableEvent(String topic, String tableName);
// Register for events on all tables
protected void registerEvent(String topic);
// Get the PO (Persistent Object) from the event
protected PO getPO(Event event);
// Add an error message that prevents the operation
protected void addErrorMessage(Event event, String errorMessage);
// Set an event property
protected void setEventProperty(Event event, String key, Object value);
}
Table-Specific Event Filtering
The registerTableEvent() method filters events so your handler only receives events for the specified table. This is both a performance optimization and a safety measure — you do not want your Business Partner handler accidentally processing Invoice events.
@Override
protected void initialize() {
// Only handle events for these specific tables
registerTableEvent(IEventTopics.PO_BEFORE_NEW, "C_Order");
registerTableEvent(IEventTopics.PO_BEFORE_CHANGE, "C_Order");
registerTableEvent(IEventTopics.PO_BEFORE_NEW, "C_OrderLine");
registerTableEvent(IEventTopics.PO_BEFORE_CHANGE, "C_OrderLine");
// For document events, register on the document table
registerTableEvent(IEventTopics.DOC_BEFORE_COMPLETE, "C_Order");
}
You can also use registerEvent() without a table name to receive events for all tables, but this is rarely appropriate in production code.
Accessing PO Data in Handlers
The getPO(event) method returns the Persistent Object being saved, deleted, or processed. The PO class provides a rich API for accessing and modifying record data.
Reading Field Values
PO po = getPO(event);
// Get a value by column name (returns Object)
Object value = po.get_Value("Name");
// Get a typed value
String name = po.get_ValueAsString("Name");
int bPartnerId = po.get_ValueAsInt("C_BPartner_ID");
boolean isActive = po.get_ValueAsBoolean("IsActive");
java.math.BigDecimal amount = (java.math.BigDecimal)
po.get_Value("GrandTotal");
java.sql.Timestamp date = (java.sql.Timestamp)
po.get_Value("DateOrdered");
// Get the table ID and record ID
int tableId = po.get_Table_ID();
int recordId = po.get_ID();
// Get the table name
String tableName = po.get_TableName();
Modifying Field Values (BEFORE Events Only)
In BEFORE events, you can modify field values before they are persisted:
// Set a field value (only effective in BEFORE events)
po.set_ValueOfColumn("Description",
"Auto-generated on " + new java.sql.Timestamp(
System.currentTimeMillis()));
// For typed model classes, you can cast and use setters
if (po instanceof org.compiere.model.MOrder) {
org.compiere.model.MOrder order = (org.compiere.model.MOrder) po;
order.setDescription("Auto-generated");
}
Checking If This Is a New Record
// Check if the record is being created (vs updated)
boolean isNew = po.is_new();
// Alternatively, check the event topic
if (event.getTopic().equals(IEventTopics.PO_BEFORE_NEW)) {
// This is a new record
}
Preventing Save with addError()
One of the most powerful capabilities of BEFORE event handlers is the ability to prevent the save operation. Calling addErrorMessage() stops the save and displays the error to the user.
@Override
protected void doHandleEvent(Event event) {
PO po = getPO(event);
String topic = event.getTopic();
if (IEventTopics.PO_BEFORE_NEW.equals(topic)
|| IEventTopics.PO_BEFORE_CHANGE.equals(topic)) {
// Validate: Sales Orders must have a PO Reference
if ("C_Order".equals(po.get_TableName())) {
boolean isSOTrx = po.get_ValueAsBoolean("IsSOTrx");
String poReference = po.get_ValueAsString("POReference");
if (isSOTrx &&
(poReference == null || poReference.trim().isEmpty())) {
addErrorMessage(event,
"Sales Orders require a PO Reference number. "
+ "Please enter the customer's purchase order number.");
return; // Stop processing
}
}
}
}
When addErrorMessage() is called, the save operation is aborted, the database transaction is rolled back, and the user sees the error message in the UI. This is the standard way to implement server-side validation in iDempiere plugins.
Comparing Old vs New Values
In BEFORE_CHANGE and AFTER_CHANGE events, you often need to know whether a specific field was modified and what its previous value was. The PO class provides several methods for this:
@Override
protected void doHandleEvent(Event event) {
PO po = getPO(event);
if (IEventTopics.PO_BEFORE_CHANGE.equals(event.getTopic())) {
// Check if a specific column was changed
if (po.is_ValueChanged("CreditLimit")) {
// Get the old value
java.math.BigDecimal oldLimit = (java.math.BigDecimal)
po.get_ValueOld("CreditLimit");
// Get the new value
java.math.BigDecimal newLimit = (java.math.BigDecimal)
po.get_Value("CreditLimit");
logger.info("Credit limit changed from "
+ oldLimit + " to " + newLimit
+ " for BPartner " + po.get_ValueAsString("Name"));
// Validate: credit limit cannot be increased by more
// than 50% without manager approval
if (oldLimit != null && oldLimit.signum() > 0) {
java.math.BigDecimal maxAllowed = oldLimit.multiply(
new java.math.BigDecimal("1.5"));
if (newLimit.compareTo(maxAllowed) > 0) {
addErrorMessage(event,
"Credit limit increase exceeds 50%. "
+ "Maximum allowed: " + maxAllowed
+ ". Please request manager approval.");
}
}
}
// Check if any column in a set was changed
int[] changedColumns = po.get_IDsOldValues();
// This returns column indices of all changed columns
}
}
The is_ValueChanged() Method
The is_ValueChanged(String columnName) method returns true if the specified column’s value has been modified in the current save operation. This is essential for:
- Avoiding unnecessary processing when unrelated fields change
- Triggering logic only when specific business-critical fields are modified
- Building audit trails that capture only actual changes
Event Handler Priority and Ordering
When multiple event handlers subscribe to the same event topic and table, the order in which they execute matters. iDempiere uses the OSGi service ranking mechanism to determine order.
<!-- In component.xml, set the service ranking -->
<property name="service.ranking" type="Integer" value="100"/>
Handlers with higher ranking values execute first. If no ranking is specified, the default is 0. Negative rankings execute after handlers with the default ranking.
Use cases for priority ordering:
- High priority (200+): Validation handlers that should reject invalid data before other handlers process it
- Normal priority (0-100): Business logic handlers that auto-set values or create related records
- Low priority (negative): Audit/logging handlers that should run last, after all other handlers have finished their work
Practical Example: Auto-Set Field Values on Save
Let us build a complete, practical event handler that demonstrates several techniques. This handler auto-populates fields on Sales Order lines based on business rules:
package com.example.orderplugin.event;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.logging.Level;
import org.adempiere.base.event.AbstractEventHandler;
import org.adempiere.base.event.IEventTopics;
import org.compiere.model.MBPartner;
import org.compiere.model.PO;
import org.compiere.util.CLogger;
import org.compiere.util.DB;
import org.compiere.util.Env;
import org.osgi.service.event.Event;
public class OrderLineEventHandler extends AbstractEventHandler {
private static final CLogger logger =
CLogger.getCLogger(OrderLineEventHandler.class);
@Override
protected void initialize() {
registerTableEvent(IEventTopics.PO_BEFORE_NEW, "C_OrderLine");
registerTableEvent(IEventTopics.PO_BEFORE_CHANGE, "C_OrderLine");
}
@Override
protected void doHandleEvent(Event event) {
PO po = getPO(event);
try {
applyCustomPricing(po, event);
validateMinimumQuantity(po, event);
} catch (Exception e) {
logger.log(Level.SEVERE,
"Error in OrderLineEventHandler", e);
addErrorMessage(event,
"An error occurred processing the order line: "
+ e.getMessage());
}
}
private void applyCustomPricing(PO po, Event event) {
// Only apply when quantity or product changes
if (!po.is_new()
&& !po.is_ValueChanged("QtyOrdered")
&& !po.is_ValueChanged("M_Product_ID")) {
return;
}
BigDecimal qty = (BigDecimal) po.get_Value("QtyOrdered");
if (qty == null || qty.signum() <= 0) return;
// Look up the business partner's discount group
int orderId = po.get_ValueAsInt("C_Order_ID");
String sql = "SELECT bp.C_BP_Group_ID "
+ "FROM C_Order o "
+ "JOIN C_BPartner bp ON o.C_BPartner_ID = bp.C_BPartner_ID "
+ "WHERE o.C_Order_ID = ?";
int bpGroupId = DB.getSQLValue(po.get_TrxName(), sql, orderId);
// Apply tiered discount based on quantity
BigDecimal discount = BigDecimal.ZERO;
if (qty.compareTo(new BigDecimal("100")) >= 0) {
discount = new BigDecimal("15"); // 15% for 100+
} else if (qty.compareTo(new BigDecimal("50")) >= 0) {
discount = new BigDecimal("10"); // 10% for 50-99
} else if (qty.compareTo(new BigDecimal("25")) >= 0) {
discount = new BigDecimal("5"); // 5% for 25-49
}
if (discount.signum() > 0) {
po.set_ValueOfColumn("Discount", discount);
logger.info("Applied " + discount
+ "% volume discount for qty " + qty);
}
}
private void validateMinimumQuantity(PO po, Event event) {
BigDecimal qty = (BigDecimal) po.get_Value("QtyOrdered");
if (qty != null && qty.signum() > 0
&& qty.compareTo(BigDecimal.ONE) < 0) {
addErrorMessage(event,
"Minimum order quantity is 1. "
+ "Please enter a valid quantity.");
}
}
}
Component XML for This Handler
<?xml version="1.0" encoding="UTF-8"?>
<scr:component xmlns:scr="http://www.osgi.org/xmlns/scr/v1.1.0"
name="com.example.orderplugin.event.OrderLineEventHandler"
immediate="true">
<implementation
class="com.example.orderplugin.event.OrderLineEventHandler"/>
<service>
<provide interface="org.osgi.service.event.EventHandler"/>
</service>
<property name="event.topics" type="String">
org/adempiere/base/event/*
</property>
<property name="service.ranking" type="Integer" value="50"/>
</scr:component>
Handling Document Events
Document events follow the same pattern as persistence events, but they fire during document processing (Complete, Void, Close, etc.) rather than during record save/delete:
package com.example.orderplugin.event;
import org.adempiere.base.event.AbstractEventHandler;
import org.adempiere.base.event.IEventTopics;
import org.compiere.model.MOrder;
import org.compiere.model.PO;
import org.osgi.service.event.Event;
public class OrderDocEventHandler extends AbstractEventHandler {
@Override
protected void initialize() {
registerTableEvent(IEventTopics.DOC_BEFORE_COMPLETE, "C_Order");
registerTableEvent(IEventTopics.DOC_AFTER_COMPLETE, "C_Order");
}
@Override
protected void doHandleEvent(Event event) {
PO po = getPO(event);
String topic = event.getTopic();
if (IEventTopics.DOC_BEFORE_COMPLETE.equals(topic)) {
// Validate before allowing completion
MOrder order = (MOrder) po;
if (order.isSOTrx() && order.getLines().length == 0) {
addErrorMessage(event,
"Cannot complete a Sales Order with no lines.");
return;
}
// Check credit limit
if (order.isSOTrx()) {
MBPartner bp = new MBPartner(
order.getCtx(), order.getC_BPartner_ID(),
order.get_TrxName());
if (bp.getCreditLimit().signum() > 0) {
java.math.BigDecimal openBalance =
bp.getTotalOpenBalance();
java.math.BigDecimal orderTotal =
order.getGrandTotal();
if (openBalance.add(orderTotal).compareTo(
bp.getCreditLimit()) > 0) {
addErrorMessage(event,
"Order exceeds customer credit limit. "
+ "Open balance: " + openBalance
+ ", Order total: " + orderTotal
+ ", Credit limit: "
+ bp.getCreditLimit());
}
}
}
}
if (IEventTopics.DOC_AFTER_COMPLETE.equals(topic)) {
// Post-completion actions
MOrder order = (MOrder) po;
// Send notification, update external system, etc.
}
}
}
Debugging Event Handlers
When event handlers do not behave as expected, use these debugging techniques:
Verify the Component Is Active
Connect to the OSGi console and check your component’s status:
# List all DS components
scr:list
# Show details of a specific component
scr:info com.example.orderplugin.event.OrderLineEventHandler
# Expected output should show:
# State: ACTIVE
# Service: {org.osgi.service.event.EventHandler}={...}
# References:
# - Satisfied
Add Diagnostic Logging
Add logging at the entry point of your handler to confirm events are being received:
@Override
protected void doHandleEvent(Event event) {
PO po = getPO(event);
logger.info("Event received: topic=" + event.getTopic()
+ " table=" + po.get_TableName()
+ " record=" + po.get_ID());
// ... rest of handler
}
Check Event Topic Matching
A common mistake is registering for the wrong event topic. Double-check that:
- The topic string in your component.xml matches what you expect (remember the
org/adempiere/base/event/prefix with forward slashes, not dots) - Your
initialize()method registers for the correct combination of topics and table names - You are listening for the right event type (PO_BEFORE_NEW vs PO_BEFORE_CHANGE vs DOC_BEFORE_COMPLETE)
Exception Handling
Always wrap your handler logic in a try-catch block. An unhandled exception in an event handler can cause unpredictable behavior — the save might succeed without your validation, or it might fail with a confusing error message.
@Override
protected void doHandleEvent(Event event) {
try {
// Your handler logic here
} catch (Exception e) {
logger.log(Level.SEVERE,
"Unexpected error in event handler", e);
addErrorMessage(event,
"Internal error: " + e.getMessage());
}
}
Breakpoint Debugging in Eclipse
When running iDempiere from Eclipse in debug mode, you can set breakpoints in your event handler code just like any other Java code. The debugger will pause execution when the breakpoint is hit, allowing you to inspect the PO values, step through your logic, and verify your assumptions.
Best Practices
- Keep handlers focused. Each handler should handle one table or one business concern. Do not create a single “catch-all” handler for all tables.
- Check is_ValueChanged(). Before performing expensive operations (database queries, external API calls), verify that the relevant fields actually changed.
- Handle null values. PO field values can be null. Always check for null before performing operations on returned values.
- Use the same transaction. When creating or modifying related records in an AFTER event, use the same transaction name (
po.get_TrxName()) to ensure atomicity. - Avoid infinite loops. If your BEFORE_CHANGE handler calls
po.set_ValueOfColumn(), be aware that this could trigger another BEFORE_CHANGE event if the PO is saved again. Use flags or checkis_ValueChanged()to break the loop. - Log judiciously. Use
Level.INFOfor significant business events andLevel.FINEfor diagnostic details. Excessive INFO logging in high-frequency handlers will flood your server log.
Key Takeaways
- iDempiere’s event system fires synchronous events at defined lifecycle points during record persistence (PO_BEFORE_NEW, PO_AFTER_CHANGE, etc.) and document processing (DOC_BEFORE_COMPLETE, etc.).
- Use
AbstractEventHandleras your base class — it providesregisterTableEvent(),getPO(), andaddErrorMessage()convenience methods. - BEFORE events allow you to validate data, prevent saves (with
addErrorMessage()), and modify field values. AFTER events are for follow-up actions that depend on the save having succeeded. - Use
is_ValueChanged()andget_ValueOld()to detect and respond to specific field changes, avoiding unnecessary processing. - Control execution order with
service.rankingin component.xml — higher values execute first. - Always wrap handler logic in try-catch blocks and verify component activation in the OSGi console when debugging.
What’s Next
Event handlers react to changes in existing data. In the next lesson, you will learn to create custom processes that perform batch operations and custom forms that provide specialized user interfaces — the two other primary extension points for iDempiere plugin developers.