Model Events and Event Handlers

Level: Intermediate Module: Plugin Development 16 min read Lesson 22 of 47

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:

  1. Event Producer — The iDempiere core (specifically, the PO base class and the Document Engine) fires events at defined lifecycle points.
  2. Event Bus — The IEventManager service (wrapping OSGi Event Admin) routes events to registered handlers based on topic matching.
  3. 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 check is_ValueChanged() to break the loop.
  • Log judiciously. Use Level.INFO for significant business events and Level.FINE for 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 AbstractEventHandler as your base class — it provides registerTableEvent(), getPO(), and addErrorMessage() 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() and get_ValueOld() to detect and respond to specific field changes, avoiding unnecessary processing.
  • Control execution order with service.ranking in 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.

You Missed