The Model Layer (X_ and M_ Classes)

Level: Intermediate Module: General Foundation 15 min read Lesson 17 of 47

Overview

  • What you’ll learn: iDempiere’s persistence layer through the PO base class, auto-generated X_ classes, extensible M_ classes, lifecycle methods, and the Query API for database operations.
  • Prerequisites: Lessons 1-16, Java programming knowledge, Eclipse development environment
  • Estimated reading time: 30 minutes

Introduction

Every table in iDempiere has a corresponding Java class that handles persistence (reading from and writing to the database), business logic, and data validation. These classes form the Model Layer — the backbone of all iDempiere development. Understanding the model layer is not optional; virtually every customization, plugin, or extension you build will interact with it.

This lesson covers the three-tier class hierarchy (PO, X_, M_), the code generation process, lifecycle hooks for injecting business logic, and the Query API for database operations.

The PO (Persistent Object) Base Class

At the root of every model class is org.compiere.model.PO (Persistent Object). This abstract class provides the complete persistence framework:

What PO Does

  • Database CRUD: load(), save(), saveEx(), delete(), deleteEx()
  • Change tracking: Tracks which columns have been modified since the last save.
  • Audit trail: Automatically manages Created, CreatedBy, Updated, UpdatedBy columns.
  • Multi-tenancy: Enforces AD_Client_ID and AD_Org_ID security.
  • Transaction management: Operations run within a database transaction (trxName).
  • Lifecycle hooks: beforeSave(), afterSave(), beforeDelete(), afterDelete().
  • Change log: Records field-level changes to the AD_ChangeLog table.

Core PO Methods

// Every model class inherits these from PO:

// Loading
PO.load(trxName)                    // Load record from database
PO.get_ID()                         // Get the primary key value
PO.get_TableName()                  // Get the table name

// Saving
PO.save()                           // Save; returns boolean
PO.saveEx()                         // Save; throws exception on failure (PREFERRED)

// Deleting
PO.delete(boolean force)            // Delete; returns boolean
PO.deleteEx(boolean force)          // Delete; throws exception on failure

// Generic field access
PO.get_Value(String columnName)     // Get value as Object
PO.get_ValueAsInt(String columnName) // Get value as int
PO.get_ValueAsString(String columnName) // Get value as String
PO.get_ValueAsBoolean(String columnName) // Get value as boolean
PO.set_Value(String columnName, Object value) // Set value

// Change detection
PO.is_new()                         // Is this a new (unsaved) record?
PO.is_Changed()                     // Has any column been modified?
PO.is_ValueChanged(String columnName) // Has this specific column changed?
PO.get_ValueOld(String columnName)  // Get the previous value

// Context and transaction
PO.getCtx()                         // Get the Properties context
PO.get_TrxName()                    // Get the current transaction name
PO.getAD_Client_ID()                // Get the Client ID
PO.getAD_Org_ID()                   // Get the Organization ID

X_ Classes: The Generated Layer

For every table registered in the Application Dictionary, iDempiere can generate an X_ class. These classes are auto-generated and provide type-safe getters and setters for every column.

What Gets Generated

Given a table C_Order with columns like DocumentNo, GrandTotal, C_BPartner_ID, the generator creates:

// Auto-generated: X_C_Order.java
public class X_C_Order extends PO implements I_C_Order {

    /** Table ID */
    public static final int Table_ID = 259;
    /** Table Name */
    public static final String Table_Name = "C_Order";

    // Standard constructors
    public X_C_Order(Properties ctx, int C_Order_ID, String trxName) {
        super(ctx, C_Order_ID, trxName);
    }

    public X_C_Order(Properties ctx, ResultSet rs, String trxName) {
        super(ctx, rs, trxName);
    }

    // Type-safe getter and setter for DocumentNo (String column)
    public void setDocumentNo(String DocumentNo) {
        set_Value(COLUMNNAME_DocumentNo, DocumentNo);
    }
    public String getDocumentNo() {
        return (String) get_Value(COLUMNNAME_DocumentNo);
    }

    // Type-safe getter and setter for GrandTotal (BigDecimal column)
    public void setGrandTotal(BigDecimal GrandTotal) {
        set_Value(COLUMNNAME_GrandTotal, GrandTotal);
    }
    public BigDecimal getGrandTotal() {
        BigDecimal bd = (BigDecimal) get_Value(COLUMNNAME_GrandTotal);
        if (bd == null) return Env.ZERO;
        return bd;
    }

    // Foreign key: C_BPartner_ID
    public void setC_BPartner_ID(int C_BPartner_ID) {
        if (C_BPartner_ID < 1)
            set_Value(COLUMNNAME_C_BPartner_ID, null);
        else
            set_Value(COLUMNNAME_C_BPartner_ID, C_BPartner_ID);
    }
    public int getC_BPartner_ID() {
        return get_ValueAsInt(COLUMNNAME_C_BPartner_ID);
    }

    // Column name constants
    public static final String COLUMNNAME_DocumentNo = "DocumentNo";
    public static final String COLUMNNAME_GrandTotal = "GrandTotal";
    public static final String COLUMNNAME_C_BPartner_ID = "C_BPartner_ID";
    // ... more columns
}

Key Points About X_ Classes

  • Never edit X_ classes manually. They are regenerated when columns change, and your edits will be overwritten.
  • They extend PO directly and implement the corresponding I_ interface.
  • They provide null-safe getters (numeric types return 0 or Env.ZERO instead of null).
  • They define column name constants (COLUMNNAME_xxx) used throughout the codebase.
  • Each class has two constructors: one that loads by ID and one that loads from a ResultSet.

M_ Classes: The Business Logic Layer

M_ classes extend the corresponding X_ class and contain hand-written business logic. This is where developers add validations, calculations, document processing, and custom behavior.

// Hand-written: MOrder.java
public class MOrder extends X_C_Order implements DocAction {

    // Constructor delegates to generated class
    public MOrder(Properties ctx, int C_Order_ID, String trxName) {
        super(ctx, C_Order_ID, trxName);
    }

    // Business logic: validate before saving
    @Override
    protected boolean beforeSave(boolean newRecord) {
        // Validate that the business partner is active
        MBPartner bp = MBPartner.get(getCtx(), getC_BPartner_ID());
        if (bp == null || !bp.isActive()) {
            log.saveError("Error", "Business Partner is not active");
            return false;
        }

        // Auto-set the warehouse from the organization if not set
        if (getM_Warehouse_ID() == 0) {
            MOrgInfo orgInfo = MOrgInfo.get(getCtx(),
                getAD_Org_ID(), get_TrxName());
            if (orgInfo.getM_Warehouse_ID() > 0) {
                setM_Warehouse_ID(orgInfo.getM_Warehouse_ID());
            }
        }

        return true;  // Allow save to proceed
    }

    // Business logic: actions after saving
    @Override
    protected boolean afterSave(boolean newRecord, boolean success) {
        if (!success) return false;

        // If the business partner changed, update related records
        if (is_ValueChanged(COLUMNNAME_C_BPartner_ID)) {
            updateOrderLines();
        }

        return true;
    }

    // Custom business methods
    public MOrderLine[] getLines() {
        // Return order lines for this order
        return getLines(false, null);
    }

    public BigDecimal calculateTotalWeight() {
        BigDecimal weight = Env.ZERO;
        for (MOrderLine line : getLines()) {
            MProduct product = line.getProduct();
            if (product != null && product.getWeight() != null) {
                weight = weight.add(
                    product.getWeight().multiply(line.getQtyOrdered()));
            }
        }
        return weight;
    }

    // ... document processing methods (prepareIt, completeIt, etc.)
}

The Relationship: PO → X_ → M_

PO (abstract)
 └── X_C_Order (generated: getters, setters, column constants)
      └── MOrder (hand-written: business logic, validations, processing)

PO (abstract)
 └── X_C_Invoice (generated)
      └── MInvoice (hand-written)

PO (abstract)
 └── X_C_Payment (generated)
      └── MPayment (hand-written)

The Model Generation Process

When you add or modify columns in the Application Dictionary, you need to regenerate the X_ and I_ classes. iDempiere includes a built-in tool for this.

Using the Generate Model Tool

  1. In iDempiere, navigate to Application Dictionary > Generate Model (it is a process, not a window).
  2. Configure the parameters:
    • Directory: The source directory path (e.g., /path/to/idempiere/org.adempiere.base/src/).
    • Package: The Java package (e.g., org.compiere.model).
    • Table: Select the specific table, or leave blank to regenerate all.
    • EntityType: Filter by entity type (e.g., “D” for dictionary, “U” for user).
  3. Run the process. It generates/regenerates the X_ and I_ files.

What Gets Generated vs. What You Write

Generated (DO NOT EDIT):
  I_C_Order.java     -- Interface with method signatures
  X_C_Order.java     -- Implementation with getters/setters

Hand-written (YOUR CODE):
  MOrder.java        -- Business logic extending X_C_Order

Lifecycle Methods

The lifecycle methods are the primary extension points in the model layer. PO calls them at specific moments during the persistence lifecycle, and M_ classes override them to inject business logic.

beforeSave(boolean newRecord)

Called before a record is written to the database. Use it for validation and data preparation.

@Override
protected boolean beforeSave(boolean newRecord) {
    // Parameter: newRecord is true for INSERT, false for UPDATE

    // Validate business rules
    if (getQtyOrdered().signum() <= 0) {
        log.saveError("Error", "Quantity must be positive");
        return false;  // Prevents save
    }

    // Calculate derived values
    setLineNetAmt(getQtyOrdered().multiply(getPriceActual()));

    // Set defaults for new records
    if (newRecord) {
        if (getDocStatus() == null) {
            setDocStatus(DOCSTATUS_Drafted);
        }
    }

    return true;  // Allow save
}

afterSave(boolean newRecord, boolean success)

Called after the record is written to the database (but still within the transaction). Use it for cascading updates and related record creation.

@Override
protected boolean afterSave(boolean newRecord, boolean success) {
    if (!success) return false;  // Always check this first

    // Update parent record's total when a line is saved
    if (is_ValueChanged(COLUMNNAME_LineNetAmt) || newRecord) {
        MOrder order = new MOrder(getCtx(), getC_Order_ID(), get_TrxName());
        order.updateTotalLines();
        order.saveEx();
    }

    // Create default sub-records for new records
    if (newRecord) {
        createDefaultTaxLine();
    }

    return true;
}

beforeDelete()

Called before a record is deleted. Use it to prevent deletion under certain conditions or to clean up related records.

@Override
protected boolean beforeDelete() {
    // Prevent deletion of completed documents
    if (DOCSTATUS_Completed.equals(getDocStatus())) {
        log.saveError("Error", "Cannot delete completed documents");
        return false;
    }

    // Delete child records first (if cascading is needed)
    for (MOrderLine line : getLines()) {
        line.deleteEx(true);
    }

    return true;
}

afterDelete(boolean success)

Called after deletion. Use it for cleanup operations that depend on the record being removed.

@Override
protected boolean afterDelete(boolean success) {
    if (!success) return false;

    // Update parent totals after line deletion
    MOrder order = new MOrder(getCtx(), getC_Order_ID(), get_TrxName());
    order.updateTotalLines();
    order.saveEx();

    return true;
}

Creating, Saving, and Deleting Records Programmatically

Creating a New Record

// Create a new Business Partner
MBPartner bp = new MBPartner(ctx, 0, trxName);  // ID=0 means new record
bp.setValue("VENDOR001");
bp.setName("Acme Supplies");
bp.setIsVendor(true);
bp.setIsCustomer(false);
bp.setC_BP_Group_ID(1000000);  // Business Partner Group
bp.saveEx();  // Throws AdempiereException on failure

int newId = bp.getC_BPartner_ID();  // Get the auto-generated ID
log.info("Created BPartner with ID: " + newId);

Loading and Updating an Existing Record

// Load an existing order by ID
MOrder order = new MOrder(ctx, 1000123, trxName);

// Verify it loaded (ID > 0 means it exists)
if (order.get_ID() == 0) {
    throw new AdempiereException("Order not found");
}

// Modify fields
order.setDescription("Updated via code");
order.setPriorityRule(MOrder.PRIORITYRULE_High);
order.saveEx();

Deleting a Record

// Load and delete
MOrderLine line = new MOrderLine(ctx, lineId, trxName);
line.deleteEx(true);  // force=true means delete even with dependencies

// Or use the boolean version if you want to handle failure gracefully
boolean deleted = line.delete(true);
if (!deleted) {
    log.warning("Failed to delete order line: " + lineId);
}

MTable.get() for Dynamic Table Access

When you do not know the table at compile time, use MTable to create PO instances dynamically:

// Get the table definition
MTable table = MTable.get(ctx, "C_Order");

// Load a record by ID
PO record = table.getPO(1000123, trxName);

// Access fields generically
String docNo = record.get_ValueAsString("DocumentNo");
int bpId = record.get_ValueAsInt("C_BPartner_ID");

// Create a new record
PO newRecord = table.getPO(0, trxName);
newRecord.set_Value("DocumentNo", "NEW-001");
newRecord.saveEx();

The Query Class

The Query class provides a fluent API for building database queries without writing raw SQL. It is type-safe, injection-resistant, and the recommended way to query data in iDempiere.

Basic Query Examples

import org.compiere.model.Query;

// Find all active orders for a specific business partner
List<MOrder> orders = new Query(ctx, MOrder.Table_Name,
        "C_BPartner_ID=? AND IsActive='Y'", trxName)
    .setParameters(bpartnerId)
    .setOrderBy("DateOrdered DESC")
    .list();

// Find a single record
MOrder order = new Query(ctx, MOrder.Table_Name,
        "DocumentNo=?", trxName)
    .setParameters("SO-001234")
    .setClient_ID()              // Automatically filter by client
    .firstOnly();                // Returns null if not found,
                                 // throws if multiple found

// Count records
int count = new Query(ctx, MOrder.Table_Name,
        "DocStatus=? AND DateOrdered>=?", trxName)
    .setParameters(MOrder.DOCSTATUS_Completed, startDate)
    .count();

// Get just the IDs (more efficient when you don't need full objects)
int[] orderIds = new Query(ctx, MOrder.Table_Name,
        "C_BPartner_ID=?", trxName)
    .setParameters(bpartnerId)
    .getIDs();

Advanced Query Features

// Aggregate query
BigDecimal totalSales = new Query(ctx, MOrder.Table_Name,
        "IsSOTrx='Y' AND DocStatus IN ('CO','CL')", trxName)
    .setClient_ID()
    .aggregate("GrandTotal", Query.AGGREGATE_SUM);

// First with specific ordering
MOrderLine cheapestLine = new Query(ctx, MOrderLine.Table_Name,
        "C_Order_ID=?", trxName)
    .setParameters(orderId)
    .setOrderBy("PriceActual ASC")
    .first();

// Iterate over large result sets efficiently
new Query(ctx, MOrder.Table_Name,
        "DocStatus=?", trxName)
    .setParameters(MOrder.DOCSTATUS_Drafted)
    .setClient_ID()
    .forEach(order -> {
        // Process each order without loading all into memory
        processOrder((MOrder) order);
    });

Understanding the I_ Interfaces

For every table, the generator also creates an I_ interface (e.g., I_C_Order). These interfaces define the public contract of the model class:

// Generated: I_C_Order.java
public interface I_C_Order {
    public int getC_Order_ID();
    public String getDocumentNo();
    public void setDocumentNo(String DocumentNo);
    public BigDecimal getGrandTotal();
    public void setGrandTotal(BigDecimal GrandTotal);
    public int getC_BPartner_ID();
    public I_C_BPartner getC_BPartner() throws RuntimeException;
    // ... all column accessors
}

The interfaces are useful for type-safe programming when you work with generic PO references. They also define navigation methods for foreign keys — notice getC_BPartner() returns the related I_C_BPartner object, not just the ID.

ModelFactory: How iDempiere Resolves Classes

When iDempiere needs to instantiate a model object, it does not simply call new X_C_Order(). Instead, it uses the ModelFactory pattern to resolve the correct class:

  1. The framework receives a table name and record ID.
  2. It queries all registered IModelFactory implementations (via OSGi services).
  3. Each factory is asked: “Can you create an instance for this table?”
  4. The default factory checks for an M_ class first. If MOrder exists, it creates an MOrder instance.
  5. If no M_ class exists, it falls back to the X_ class.
  6. If no X_ class exists, it creates a generic GenericPO instance.
// This is why MTable.getPO() returns MOrder, not X_C_Order:
PO record = MTable.get(ctx, "C_Order").getPO(orderId, trxName);
// record is actually an MOrder instance

// You can verify:
log.info(record.getClass().getName());
// Output: org.compiere.model.MOrder

Custom ModelFactory for Plugins

Plugins can register their own ModelFactory to override the default class resolution. This allows a plugin to substitute its own model class for a core table:

public class MyModelFactory implements IModelFactory {

    @Override
    public Class<?> getClass(String tableName) {
        if ("C_Order".equals(tableName)) {
            return MyCustomOrder.class;  // Your extended MOrder
        }
        return null;  // Let other factories handle it
    }

    @Override
    public PO getPO(String tableName, int Record_ID,
                     String trxName) {
        if ("C_Order".equals(tableName)) {
            return new MyCustomOrder(Env.getCtx(), Record_ID, trxName);
        }
        return null;
    }
}

Practical Example: Building a Custom Model Class

Let us put it all together by building a model class for a custom Expense Report table:

// Step 1: Generated X_ class (created by Generate Model tool)
// X_Z_ExpenseReport.java — DO NOT EDIT
public class X_Z_ExpenseReport extends PO implements I_Z_ExpenseReport {

    public static final int Table_ID = 1000100;
    public static final String Table_Name = "Z_ExpenseReport";

    public X_Z_ExpenseReport(Properties ctx, int Z_ExpenseReport_ID,
                              String trxName) {
        super(ctx, Z_ExpenseReport_ID, trxName);
    }

    // Generated getters/setters...
    public void setDescription(String Description) {
        set_Value("Description", Description);
    }
    public String getDescription() {
        return (String) get_Value("Description");
    }
    public void setTotalAmt(BigDecimal TotalAmt) {
        set_Value("TotalAmt", TotalAmt);
    }
    public BigDecimal getTotalAmt() {
        BigDecimal bd = (BigDecimal) get_Value("TotalAmt");
        if (bd == null) return Env.ZERO;
        return bd;
    }
    // ... more generated code
}

// Step 2: Hand-written M_ class — YOUR BUSINESS LOGIC
public class MZExpenseReport extends X_Z_ExpenseReport {

    private static final CLogger log =
        CLogger.getCLogger(MZExpenseReport.class);

    public MZExpenseReport(Properties ctx, int id, String trxName) {
        super(ctx, id, trxName);
    }

    public MZExpenseReport(Properties ctx, ResultSet rs, String trxName) {
        super(ctx, rs, trxName);
    }

    /** Get all expense lines for this report */
    public MZExpenseLine[] getLines() {
        List<MZExpenseLine> lines = new Query(getCtx(),
                MZExpenseLine.Table_Name,
                "Z_ExpenseReport_ID=?", get_TrxName())
            .setParameters(getZ_ExpenseReport_ID())
            .setOrderBy("Line")
            .list();
        return lines.toArray(new MZExpenseLine[0]);
    }

    /** Recalculate the total from lines */
    public void updateTotalAmt() {
        BigDecimal total = new Query(getCtx(),
                MZExpenseLine.Table_Name,
                "Z_ExpenseReport_ID=?", get_TrxName())
            .setParameters(getZ_ExpenseReport_ID())
            .aggregate("Amt", Query.AGGREGATE_SUM);
        if (total == null) total = Env.ZERO;
        setTotalAmt(total);
    }

    @Override
    protected boolean beforeSave(boolean newRecord) {
        // Validate: description is required
        if (getDescription() == null
            || getDescription().trim().isEmpty()) {
            log.saveError("FillMandatory", "Description");
            return false;
        }

        // Recalculate total before saving
        updateTotalAmt();

        return true;
    }

    @Override
    protected boolean afterSave(boolean newRecord, boolean success) {
        if (!success) return false;

        // Log creation of new expense reports
        if (newRecord) {
            log.info("New expense report created: "
                + getDocumentNo() + " Total: " + getTotalAmt());
        }

        return true;
    }

    @Override
    protected boolean beforeDelete() {
        // Only allow deletion of draft reports
        if (!"DR".equals(getDocStatus())) {
            log.saveError("Error",
                "Only draft expense reports can be deleted");
            return false;
        }
        return true;
    }

    /** Static helper: find reports pending approval */
    public static List<MZExpenseReport> getPendingApproval(
            Properties ctx, String trxName) {
        return new Query(ctx, Table_Name,
                "DocStatus='DR' AND IsApproved='N'", trxName)
            .setClient_ID()
            .setOrderBy("Created")
            .list();
    }
}

Key Takeaways

  • The model layer has three tiers: PO (persistence base), X_ (generated getters/setters), M_ (hand-written business logic).
  • Never edit X_ classes — they are regenerated. All custom logic goes in M_ classes.
  • Use lifecycle methods (beforeSave, afterSave, beforeDelete, afterDelete) to inject business rules at the persistence level.
  • Always use saveEx() and deleteEx() (the exception-throwing variants) in application code.
  • The Query class provides a fluent, type-safe API for database queries — prefer it over raw SQL.
  • MTable.get().getPO() dynamically instantiates the correct M_ class thanks to the ModelFactory pattern.
  • Plugins can register custom IModelFactory implementations to override default class resolution.
  • The I_ interfaces define the public contract and enable type-safe access to related records via navigation methods.

What’s Next

With a solid understanding of the model layer, you are ready to explore more advanced development topics. In the next lessons, you will learn about Model Validators (event-driven hooks that complement lifecycle methods), building OSGi plugins, and creating custom processes and reports that leverage the model layer you now understand.

You Missed