Classes, Events, and Extensions
Create X++ classes in the AOT, extend standard application behaviour using the extension model and event handlers, and apply attributes like SysEntryPointAttribute and DataMemberAttribute. This module covers AOT class design β Domain 4 covers X++ coding patterns in depth.
Classes in D365 F&O
Think of a recipe card.
A recipe card (class) has two parts: the ingredients list (variables/properties) and the cooking steps (methods). You can have a recipe for βChocolate Cakeβ and another for βVanilla Cakeβ β they share the same structure (both are cakes) but have different ingredients and steps.
In D365, a class is a recipe card for a business process. The βPost Invoiceβ class has the data it needs (which invoice, which customer) and the steps to follow (validate, calculate tax, create voucher, update balances). Other developers can extend the recipe β adding a step like βsend email notificationβ β without rewriting the whole thing.
The AOT (Application Object Tree) is the filing cabinet where all these recipe cards are stored and organised.
Creating a class
In Visual Studio: right-click your project β Add New Item β Code β Class.
Class structure
/// <summary>
/// Handles inspection validation logic for Axion Dynamics.
/// </summary>
class AxionInspectionValidator
{
// Member variables (state)
AxionInspectionTable inspection;
boolean isValid;
// Constructor β protected so callers use construct() or newFrom()
protected void new()
{
}
// Static construct method β standard factory pattern
public static AxionInspectionValidator construct()
{
return new AxionInspectionValidator();
}
// Initialise from an inspection record
public AxionInspectionValidator initFromInspection(AxionInspectionTable _inspection)
{
inspection = _inspection;
return this; // fluent pattern β allows method chaining
}
// Business logic method
public boolean validate()
{
isValid = true;
if (!inspection.InspectionId)
{
isValid = checkFailed("@AxionLabels:InspectionIdRequired");
}
if (inspection.InspectionDate > today())
{
isValid = checkFailed("@AxionLabels:FutureDateNotAllowed");
}
if (!HcmWorker::exist(inspection.InspectorWorker))
{
isValid = checkFailed("@AxionLabels:InspectorNotFound");
}
return isValid;
}
}
Calling the class
// Usage pattern
AxionInspectionValidator validator = AxionInspectionValidator::construct()
.initFromInspection(inspectionRecord);
if (validator.validate())
{
// proceed with posting
}
Exam tip: construct() vs new()
The standard pattern in D365 F&O is:
new()βprotected, never called directly from outside the classconstruct()βpublic static, the factory method callers use
Why? This pattern allows Microsoft (or you) to introduce subclasses later without breaking callers. The construct method can return a subclass based on parameters.
The exam may ask: βA developer calls new AxionInspectionValidator() directly. What is wrong?β Answer: the new() method is protected β use the static construct() method instead.
Access modifiers
| Modifier | Visibility | Use Case |
|---|---|---|
| public | Accessible from anywhere | API methods, static helpers |
| protected | Accessible from this class and subclasses | Internal state management, overridable logic |
| private | Accessible from this class only | Implementation details, helper methods |
| internal | Accessible from the same model only | Cross-class helpers within your model, not exposed to other models |
Extending standard classes
Since Microsoftβs classes are sealed (no inheritance), you use two patterns to add or modify behaviour:
Pattern 1: Class extensions (augmentation)
A class extension adds new methods to an existing class without modifying the original:
// Adds a new method to InventTable (Microsoft's class)
[ExtensionOf(tableStr(InventTable))]
final class AxionInventTable_Extension
{
// New method β callable as inventTable.axionGetInspectionRequired()
public boolean axionGetInspectionRequired()
{
// Access the current record via 'this'
return this.AxionInspectionRequired == NoYes::Yes;
}
}
Pattern 2: Event handlers (subscribe to events)
Event handlers run before, after, or around a standard method:
// Subscribes to the validateWrite event on CustTable
class AxionCustTable_EventHandler
{
// Runs after CustTable.validateWrite() β adds extra validation
[DataEventHandler(tableStr(CustTable), DataEventType::ValidatingWrite)]
public static void CustTable_onValidatingWrite(
Common _sender,
DataEventArgs _eventArgs)
{
ValidateEventArgs validateArgs = _eventArgs as ValidateEventArgs;
CustTable custTable = _sender as CustTable;
if (!custTable.AxionPreferredInspectorWorker)
{
validateArgs.parmValidateResult(false);
checkFailed("@AxionLabels:InspectorRequired");
}
}
}
Class extension vs event handler
| Feature | Class Extension | Event Handler |
|---|---|---|
| What it does | Adds NEW methods to an existing class/table | Subscribes to EXISTING events on a class/table |
| Can modify existing logic? | No β only adds new methods | Yes β can run before/after existing methods and modify results |
| Access to private members? | No β only public/protected members | No β receives event args and sender object |
| Syntax | [ExtensionOf(classStr(...))] final class | [PostHandlerFor(...)] or [DataEventHandler(...)] static void |
| Use case | Adding helper methods, computed values | Validation, logging, notifications, modifying standard behaviour |
| Naming convention | AxionClassName_Extension | AxionClassName_EventHandler |
Event types
Pre and post event handlers on methods
class AxionSalesConfirm_EventHandler
{
// Runs BEFORE SalesConfirmJournalPost.validate()
[PreHandlerFor(classStr(SalesConfirmJournalPost), methodStr(SalesConfirmJournalPost, validate))]
public static void preValidate(XppPrePostArgs _args)
{
// Access the instance that called validate()
SalesConfirmJournalPost instance = _args.getThis() as SalesConfirmJournalPost;
info("Pre-validation: checking Axion quality requirements...");
}
// Runs AFTER SalesConfirmJournalPost.validate()
[PostHandlerFor(classStr(SalesConfirmJournalPost), methodStr(SalesConfirmJournalPost, validate))]
public static void postValidate(XppPrePostArgs _args)
{
boolean originalResult = _args.getReturnValue();
if (originalResult)
{
// Additional validation β can override the return value
// _args.setReturnValue(false); // to fail validation
}
}
}
Data events on tables
| Event | When It Fires |
|---|---|
| onInserting / onInserted | Before / after a record is inserted |
| onUpdating / onUpdated | Before / after a record is updated |
| onDeleting / onDeleted | Before / after a record is deleted |
| onValidatingWrite | During validateWrite() β can accept or reject |
| onValidatingDelete | During validateDelete() β can accept or reject |
| onInitializedRecord | When a new record buffer is initialised (default values) |
| onModifiedField | When a field value changes on the form |
Scenario: Vik adds quality checks to sales confirmation
Axion needs a quality gate before sales order confirmation: if the order contains items flagged for inspection, confirmation should be blocked until inspection passes.
Vik cannot modify SalesConfirmJournalPost directly β itβs Microsoftβs class. Instead, he creates a PostHandlerFor on the validate() method. His handler checks whether any order lines reference inspection-required items. If an item hasnβt passed inspection, he overrides the return value to false and shows an error.
Zero changes to Microsoftβs code. When Microsoft updates the SalesConfirmJournalPost class, Vikβs handler continues to work because it subscribes to the event, not the implementation.
Key attributes
Attributes are metadata annotations that tell the runtime how to treat a class or method.
SysEntryPointAttribute
Required on every service method exposed to external callers (AIF, OData custom actions):
[SysEntryPointAttribute(true)] // true = authorisation check enforced
public AxionInspectionResult performInspection(AxionInspectionId _inspectionId)
{
// This method can be called from outside via service endpoint
// The attribute tells the security framework to check the caller's privileges
}
DataMemberAttribute
Marks properties on a data contract class for serialisation (used in service contracts, batch jobs, SysOperation framework):
[DataContractAttribute]
class AxionInspectionContract
{
AxionInspectionId inspectionId;
TransDate fromDate;
TransDate toDate;
[DataMemberAttribute('InspectionId')]
public AxionInspectionId parmInspectionId(
AxionInspectionId _inspectionId = inspectionId)
{
inspectionId = _inspectionId;
return inspectionId;
}
[DataMemberAttribute('FromDate')]
public TransDate parmFromDate(TransDate _fromDate = fromDate)
{
fromDate = _fromDate;
return fromDate;
}
[DataMemberAttribute('ToDate')]
public TransDate parmToDate(TransDate _toDate = toDate)
{
toDate = _toDate;
return toDate;
}
}
Other important attributes
| Attribute | Purpose |
|---|---|
| SysOperationContractProcessingAttribute | Links a service operation to its UI builder |
| SysEntryPointAttribute | Security entry point for service methods |
| DataContractAttribute | Marks a class as a serialisable data contract |
| DataMemberAttribute | Marks a property for serialisation |
| ExtensionOf | Declares a class extension (augmentation) |
| PreHandlerFor / PostHandlerFor | Subscribes to pre/post events on methods |
| ExportAttribute | Marks a class for dependency injection |
The parm* naming convention
D365 F&O uses a distinctive pattern for getter/setter methods:
// The parm method acts as both getter and setter
[DataMemberAttribute('InspectionId')]
public AxionInspectionId parmInspectionId(
AxionInspectionId _inspectionId = inspectionId)
{
inspectionId = _inspectionId;
return inspectionId;
}
// Calling as getter:
AxionInspectionId id = contract.parmInspectionId();
// Calling as setter:
contract.parmInspectionId('INS-001');
The default parameter (= inspectionId) makes this work: when called without arguments, it returns the current value. When called with an argument, it sets and returns the new value.
Exam tip: parm methods and data contracts
The exam frequently tests the parm pattern:
- Naming:
parm+ PropertyName (e.g.,parmFromDate) - Default parameter: Must default to the member variable
- DataMemberAttribute: Required for serialisation
- Return type: Must match the member variable type
If a question shows a data contract with [DataMemberAttribute] on a method that doesnβt follow the parm pattern, thatβs likely the error theyβre testing.
Extension vs overlayering: the fundamental rule
| Approach | Overlayering (BANNED) | Extension (REQUIRED) |
|---|---|---|
| How it works | Directly modifying Microsoft's source code | Adding new elements or subscribing to events in a separate model |
| Allowed in D365 F&O? | No β blocked for most models since Platform Update 9+ | Yes β the only supported customisation approach |
| Upgrade impact | Every Microsoft update could break your changes | Extensions survive updates β they're separate from the base code |
| Techniques | Copy and edit standard class/method | Class extension, event handler, table extension, form extension |
| Code conflicts | Yes β merge conflicts on every update | No β your code is in a separate model |
Scenario: Sophie migrates from AX 2012 overlayering
Sophieβs company Ferris Industries is migrating from AX 2012 where they overlayered dozens of standard classes. Mentor Carl explains the migration path:
βIn AX 2012, we copied SalesFormLetter and added 50 lines of custom code. In D365, we canβt touch that class. Instead, we:
- Create a class extension for any new methods we need
- Create event handlers for the existing methods we modified
- Move our custom logic into these extension points
The result? When Microsoft updates SalesFormLetter, our code continues to work because itβs in a separate layer.β
Putting it together: when to use what
| Need | Approach |
|---|---|
| Add a new helper method to CustTable | Class extension with [ExtensionOf(tableStr(CustTable))] |
| Run extra validation before a standard post | [PreHandlerFor] event handler on the post method |
| Log every time an invoice is created | [PostHandlerFor] on the insert event or onInserted data event |
| Expose a new service endpoint | New class with [SysEntryPointAttribute] on the service method |
| Pass parameters to a batch job | Data contract class with [DataContractAttribute] and [DataMemberAttribute] |
| Create business logic for a new module | New class with construct() pattern, organised by responsibility |
Vik needs to add custom validation that runs whenever a CustTable record is saved. He cannot modify the standard CustTable class. Which approach should he use?
A developer creates a data contract class for a batch job. The class has [DataContractAttribute] on the class and three parm methods for FromDate, ToDate, and CustomerId. But when the batch dialog opens, the CustomerId field doesn't appear. What is the most likely cause?
Elena needs to add a method to the standard InventTable that calculates a custom inspection score. She creates: [ExtensionOf(tableStr(InventTable))] final class AxionInventTable_Extension. The method compiles but when she calls inventTable.axionCalculateScore(), she gets a compile error 'method not found'. What went wrong?
Which attribute must be applied to a service method that will be called externally via an OData custom action?
Next up: X++ Data Manipulation β dive into X++ coding patterns for selecting, inserting, updating, and deleting data (Domain 4 begins).