Table and Form Data Source Events in D365 F&O: A Practical X++ Reference
Event handlers are one of the safest ways to extend Dynamics 365 Finance and Operations, but they are also easy to overuse. The problem starts when table validation, form behavior, and business process customization all get dumped into random handlers with no clear boundary. This reference gives you the practical map: which event fires when, what belongs there, and when Chain of Command is the cleaner choice.
Table events are best for persistence rules that must follow the record
Table event handlers run with the table operation, not with a specific form. That makes them appropriate for rules that must apply whether the record is changed from a form, data entity, batch job, or service call. If the rule is only a form experience, keep it on the form data source instead.
The common table CRUD events are exposed through DataEventHandler attributes. You attach a static method to the table and event type, receive the sender record, and inspect or change values before or after the operation.
public final class TAGCustTableEventHandlers
{
[DataEventHandler(tableStr(CustTable), DataEventType::Updating)]
public static void CustTable_onUpdating(Common sender, DataEventArgs e)
{
CustTable custTable = sender as CustTable;
if (custTable.Blocked == CustVendorBlocked::All && !custTable.CreditMax)
{
throw error("A fully blocked customer must have a reviewed credit limit.");
}
}
[DataEventHandler(tableStr(CustTable), DataEventType::Inserted)]
public static void CustTable_onInserted(Common sender, DataEventArgs e)
{
CustTable custTable = sender as CustTable;
info(strFmt("Customer %1 was created.", custTable.AccountNum));
}
}
Use pre-events for control and post-events for reaction. Inserting, Updating, and Deleting are where you block invalid persistence. Inserted, Updated, and Deleted are where you enqueue follow-up work, write audit records, or signal another process after the database operation succeeds.
Form data source events are for user experience and screen state
Form data source events fire as the user moves through records, edits fields, validates writes, or completes form-level persistence. They are excellent for enabling controls, defaulting values for that screen, and validating UI-specific behavior. They are a poor place for rules that must also apply to imports or services.
| Event | When it fires | Typical use |
|---|---|---|
Activated | A record becomes current on the data source | Enable controls based on selected record |
Active | The active method runs for a data source record | Refresh dependent UI state |
ValidatingWrite | Before the form data source writes a record | Block invalid user edits on this form |
Written | After the form data source writes a record | Refresh totals or show confirmation |
Modified field event | After a field value changes on the form | Recalculate dependent fields |
Leaving field event | User leaves a field control | Light UI validation or formatting |
public final class TAGSalesTableFormHandlers
{
[FormDataSourceEventHandler(formDataSourceStr(SalesTable, SalesTable), FormDataSourceEventType::Activated)]
public static void SalesTable_OnActivated(FormDataSource sender, FormDataSourceEventArgs e)
{
SalesTable salesTable = sender.cursor();
FormRun formRun = sender.formRun();
FormControl confirmButton = formRun.design().controlName(formControlStr(SalesTable, ConfirmSalesOrder));
confirmButton.enabled(salesTable.SalesStatus == SalesStatus::Backorder);
}
[FormDataSourceEventHandler(formDataSourceStr(SalesTable, SalesTable), FormDataSourceEventType::ValidatingWrite)]
public static void SalesTable_OnValidatingWrite(FormDataSource sender, FormDataSourceEventArgs e)
{
SalesTable salesTable = sender.cursor();
if (!salesTable.CustAccount)
{
FormDataSourceCancelEventArgs cancelArgs = e as FormDataSourceCancelEventArgs;
cancelArgs.cancel(true);
warning("Customer account is required before saving the sales order.");
}
}
}
Keep these handlers small. If you need a service class, complex query, or cross-company calculation, call a domain method from the handler rather than burying business logic inside the form extension layer.
Field events should be narrow because they fire often
Field events are tempting because they feel close to the user action. They are also noisy. A modified field handler can run repeatedly during manual entry, personalization, copy operations, or programmatic value changes triggered by the form.
Use field events for immediate feedback:
- Dependent defaults: update delivery mode when the customer changes.
- Control state: enable a dimension tab when a category is selected.
- Cheap validation: warn about a missing reference before the save attempt.
Avoid field events for persistence guarantees. A data entity import will not care that your form field handler once showed a warning. If the rule matters to the table, put it on the table.
public final class TAGSalesLineFieldHandlers
{
[FormDataFieldEventHandler(formDataFieldStr(SalesTable, SalesLine, ItemId), FormDataFieldEventType::Modified)]
public static void ItemId_OnModified(FormDataObject sender, FormDataFieldEventArgs e)
{
FormDataSource salesLineDs = sender.datasource();
SalesLine salesLine = salesLineDs.cursor();
if (salesLine.ItemId)
{
salesLine.Name = InventTable::find(salesLine.ItemId).itemName();
salesLineDs.refresh();
}
}
}
Chain of Command wins when you must preserve method intent
Events are not a universal replacement for Chain of Command. If the extension depends on the exact semantics of an existing method, use CoC. That includes wrapping validateWrite, modifiedField, initValue, canSubmitToWorkflow, and service methods where the base method return value is part of the contract.
| Need | Prefer | Why |
|---|---|---|
| Add validation to all writes | Table CoC or table event | Keeps rule close to persistence |
| Toggle controls on one form | Form data source event | UI-specific and isolated |
| Extend a protected method flow | Chain of Command | You can call next and preserve intent |
| React after insert | Inserted event | No need to wrap base insert |
| Change return value | Chain of Command | Events often cannot safely replace result logic |
[ExtensionOf(tableStr(SalesTable))]
final class TAGSalesTable_Extension
{
public boolean validateWrite()
{
boolean ret = next validateWrite();
if (ret && this.SalesType == SalesType::Journal)
{
ret = checkFailed("Journal sales orders are not allowed in this process.");
}
return ret;
}
}
Ordering caveats are real, so design handlers to be independent
Multiple handlers can subscribe to the same event. You should not build a solution that depends on handler A running before handler B unless the platform gives you an explicit ordering contract for that extension point. Most event subscription code should be idempotent, local, and tolerant of other extensions.
That means avoiding hidden coupling:
- Do not rely on another handler setting a field first.
- Do not throw generic errors that mask another validation failure.
- Do not update unrelated records from a UI event without a transaction strategy.
- Do not use post-events to repair data that should have been validated before write.
The senior-engineer test is simple: if the handler would still make sense when another ISV adds its own handler tomorrow, it is probably placed correctly. Use table events for record lifecycle rules, form events for screen behavior, field events for immediate feedback, and CoC when the method contract matters. That split keeps extensions upgrade-safe without turning the event model into a guessing game.
Keep reading
update_recordset: SQL Ran, and update() Never Did
update_recordset, insert_recordset, and delete_from send SQL now. They skip table methods, CoC, and events. Stamp the columns the method would have stamped, or stay on the row path.
FormDataSource.research: The Range You Added Never Ran
research reruns the live QueryRun. A range on query() waits for executeQuery. reread reloads only the current RecId, so an insert stays off the grid.
CacheLookup in X++: Found Serves the Stale Row Inside ttsbegin
Found serves the same CustTable inside ttsbegin. NotInTTS goes to SQL. EntireTable is for parameters. No unique index means the cache never hits.
crossCompany in X++: The Query That Mixes Legal Entities
A normal select stays in the current company. crossCompany returns every legal entity the user may read. How the company container, changeCompany, and OData differ.
Budget Control in Finance 10.0.49: Closed Periods, Reservations, and Broken Tracking
The budgeting features in Dynamics 365 Finance 10.0.49 — accounting-date advancement, reservation posting, encumbrance reporting, and a diagnostic for corrupt budget tracking.
Bank and Cash in Finance 10.0.49: Delayed Settlement, Match Preview, and Bridge Selection
What Dynamics 365 Finance 10.0.49 changes in payment journals and bank reconciliation, which pieces are still preview, and which bank behaviors you can no longer disable.
Newsletter
New posts, straight to your inbox
One email when a new post goes up. Double opt-in, unsubscribe anytime. Not a daily digest.
Comments
- No comments yet. Be the first.