Data Validation, you’re doing it wrong!

In this video, I focus on how to do proper data validation in Business Central. I have seen a lot of codes that do not follow proper validation rules and have the potential to produce invalid data.

https://youtu.be/dg72Av_A-XA

And now with extensions and events, proper data validation is the key for an extension to a good app citizen.


In this video, Erik explains a fundamental concept that many AL developers get wrong: data validation in Business Central. He walks through the proper pattern for inserting and modifying records in code, emphasizing why you should always use Validate instead of direct field assignment, and why triggering Insert(true), Modify(true), and Delete(true) matters — especially in the extension-driven world of Business Central.

What Is Data Validation?

Data validation, in the context of Business Central development, refers to all the trigger code that exists on tables and fields. Whenever you do something that affects the data model — inserting a record, modifying a field, deleting a row — these triggers fire. They ensure data integrity, cascade related changes, and allow other extensions to hook into the process through event subscribers.

The Record Lifecycle Pattern

To understand proper validation, you first need to understand how Business Central handles records when a user interacts with a page. Erik walks through creating a new customer to illustrate:

  1. Init — The record is initialized with default values.
  2. Fill out the primary key — The user types in the primary key field (e.g., Customer No.).
  3. Insert — As soon as the user leaves the primary key field (tabs to the next field), the record is inserted into the database.
  4. Validate fields — Each subsequent field the user fills out triggers field validation.
  5. Modify — When the user exits the record or performs another action, the changes are written to the database.

This is the standard pattern. There is one variation: the DelayedInsert page property.

DelayedInsert

The DelayedInsert property on a page changes the insertion behavior. When set to true, the record is not inserted when the user leaves the primary key field. Instead, the user can fill out the entire record, and it only gets inserted when they leave the row (e.g., moving to the next line in a list). When false (the default), insertion happens immediately upon leaving the primary key field.

Replicating the Pattern in Code

The critical takeaway is this: when you write code that creates or modifies data in Business Central, you must replicate the exact same pattern that a user would follow manually.

Here’s an example from Erik’s Point of Sale video, where a sales line is created programmatically:

SalesLine.Init();
// Fill out primary key fields
SalesLine."Document Type" := SalesHeader."Document Type";
SalesLine."Document No." := SalesHeader."No.";
SalesLine."Line No." := LineNo;
SalesLine.Insert(true);  // Always pass true!

// Validate fields as if the user typed them
SalesLine.Validate(Type, SalesLine.Type::Item);
SalesLine.Validate("No.", ItemNo);
SalesLine.Validate(Quantity, Qty);
SalesLine.Modify(true);

Notice the pattern:

  • Init the record
  • Set the primary key fields directly (no validation needed for key fields)
  • Insert(true) — the true parameter ensures the OnInsert trigger fires
  • Validate each field you need to set
  • Modify(true) — again, true to fire the OnModify trigger

The same pattern applies for creating a sales header:

SalesHeader.Init();
SalesHeader."Document Type" := SalesHeader."Document Type"::Invoice;
SalesHeader.Insert(true);  // Number is assigned inside the OnInsert trigger

SalesHeader.Validate("Sell-to Customer No.", CustomerNo);
SalesHeader.Validate("Posting Date", WorkDate());
SalesHeader.Modify(true);

Why Direct Assignment Is Dangerous

Consider the difference between these two approaches:

// BAD - Direct assignment
SalesLine."No." := 'ITEM-001';

// GOOD - Using Validate
SalesLine.Validate("No.", 'ITEM-001');

Direct assignment is problematic for several reasons:

  • No validation logic runs. You could assign an item number that doesn’t exist, and the system won’t catch it until something tries to display or process that record.
  • No cascading field updates. The Validate trigger on the “No.” field populates description, unit price, posting groups, and many other fields. Skipping it means you get an incomplete record.
  • No event subscribers fire. In Business Central’s extension model, other apps may subscribe to OnBeforeValidateEvent or OnAfterValidateEvent on that field. Direct assignment silently bypasses all of them, making you a “bad extension citizen.”
  • Future-proofing is lost. Microsoft updates Business Central monthly. They may add logic to a validate trigger next month. If you use Validate, your code automatically picks up those changes. If you use direct assignment, you’re left behind.

Understanding Validation Events with Source Code

To demonstrate how validation triggers and events interact, Erik provides a simple table, page, and event subscriber codeunit. Here’s the table with a field that has an OnValidate trigger:

table 56100 "Validate Table"
{
    Caption = 'Validate Table';
    DataClassification = ToBeClassified;

    fields
    {
        field(1; PK; Code[10])
        {
            Caption = 'PK';
            DataClassification = ToBeClassified;
        }
        field(2; "Validate This"; Text[100])
        {
            Caption = 'Validate This';
            DataClassification = ToBeClassified;
            trigger OnValidate()
            begin
                Message('OnValidate Table Trigger');
                Rec."Validate This" := 'OnValidate Table Trigger';
            end;
        }
    }
    keys
    {
        key(PK; PK)
        {
            Clustered = true;
        }
    }
}

The page also has its own OnValidate trigger on the same field:

page 56100 "Validate Test"
{
    Caption = 'Validate Test';
    PageType = Card;
    SourceTable = "Validate Table";

    layout
    {
        area(content)
        {
            group(General)
            {
                field(PK; Rec.PK)
                {
                    ToolTip = 'Specifies the value of the PK field.';
                    ApplicationArea = All;
                }
                field("Validate This"; Rec."Validate This")
                {
                    ToolTip = 'Specifies the value of the Validate This field.';
                    ApplicationArea = All;
                    trigger OnValidate()
                    begin
                        Message('OnValidate Page Trigger');
                        Rec."Validate This" := 'OnValidate Page Trigger';
                    end;
                }
            }
        }
    }
}

And here’s where it gets interesting — a codeunit subscribing to both table-level and page-level validation events:

codeunit 56100 "Validate Events"
{
    [EventSubscriber(ObjectType::Table, Database::"Validate Table",
        'OnBeforeValidateEvent', 'Validate This', true, true)]
    local procedure MyProcedure(var Rec: Record "Validate Table";
        var xRec: Record "Validate Table"; CurrFieldNo: Integer)
    begin
        Message('OnBeforeValidateEvent Table Trigger');
        Rec."Validate This" := 'OnBeforeValidateEvent Table Trigger';
    end;

    [EventSubscriber(ObjectType::Table, Database::"Validate Table",
        'OnAfterValidateEvent', 'Validate This', true, true)]
    local procedure MyProcedure2(var Rec: Record "Validate Table";
        var xRec: Record "Validate Table"; CurrFieldNo: Integer)
    begin
        Message('OnAfterValidateEvent Table Trigger');
        Rec."Validate This" := 'OnAfterValidateEvent Table Trigger';
    end;

    [EventSubscriber(ObjectType::Page, Page::"Validate Test",
        'OnBeforeValidateEvent', 'Validate This', true, true)]
    local procedure MyProcedure3(var Rec: Record "Validate Table";
        var xRec: Record "Validate Table")
    begin
        Message('OnBeforeValidateEvent Page Trigger');
        Rec."Validate This" := 'OnBeforeValidateEvent Page Trigger';
    end;

    [EventSubscriber(ObjectType::Page, Page::"Validate Test",
        'OnAfterValidateEvent', 'Validate This', true, true)]
    local procedure MyProcedure4(var Rec: Record "Validate Table";
        var xRec: Record "Validate Table")
    begin
        Message('OnAfterValidateEvent Page Trigger');
        Rec."Validate This" := 'OnAfterValidateEvent Page Trigger';
    end;
}

This demonstrates the chain of events that fires when validation occurs. When you call Rec.Validate("Validate This", SomeValue) from code, the table-level events (OnBeforeValidateEvent, the field’s OnValidate trigger, and OnAfterValidateEvent) all fire. The page-level triggers and events only fire when the validation originates from the UI. This is an important distinction to understand — calling Validate in code triggers the table-level chain, which is exactly what you want.

When Might You Skip Validation?

There are legitimate cases where you might not want to validate every field:

  • Data imports with pre-validated data. If you’re importing data that has already been validated externally, you may want to fill out fields directly because you already have the complete, correct dataset.
  • Trigger side effects you don’t want. Sometimes a validate trigger does something you explicitly don’t need. In those cases, it’s your responsibility to manually handle the parts of the trigger logic that you do need.
  • Calculated fields. Don’t validate fields that calculate backwards. For example, on a sales line, if you validate Quantity, Unit Price, and Line Discount %, don’t also validate Amount — that field calculates from the others, and validating it would reverse-calculate the discount.

However, these should be conscious, deliberate exceptions. Your default should always be to validate.

Retrain Your Muscle Memory

Erik makes a practical suggestion: retrain your keyboard habits. Instead of reflexively typing:

// Stop doing this by default
Rec."Posting Date" := Today;

Train yourself to always type:

// Do this instead
Rec.Validate("Posting Date", Today);

Make Validate your default. Only fall back to direct assignment when you have a specific, justified reason.

Keep Validation Code UI-Free

One last critical point: your validation code (the OnValidate triggers on table fields) should not contain UI elements like Confirm, Message, or Page.RunModal. In Business Central, your validation code can be called from contexts where there is no user interface:

  • API calls
  • Task Scheduler (job queue)
  • Background sessions

If you absolutely must include UI interactions in validation logic, wrap them with GuiAllowed:

if GuiAllowed then
    if not Confirm('Are you sure?') then
        Error('Operation cancelled.');

Better yet, Erik recommends keeping UI logic entirely in the presentation layer — the page triggers — and keeping table validation purely about business logic.

Summary

The rules for proper data validation in Business Central AL development are straightforward:

  1. Always use Validate when setting field values on records. Make it your default.
  2. Always pass true to Insert, Modify, and Delete to ensure table triggers fire.
  3. Follow the Init → Set PK → Insert → Validate Fields → Modify pattern, mirroring what a user would do manually.
  4. Put validation logic in table triggers, not in random codeunits or page triggers.
  5. Keep validation code UI-free. Use GuiAllowed if you must, but prefer moving UI interactions to the page layer.
  6. Be a good extension citizen. Other extensions may subscribe to your table’s validation events. Skipping validation breaks their code silently.

Business Central updates monthly, and Microsoft can add logic to validation triggers at any time. By consistently using Validate, your code stays compatible and correct — not just today, but with every future update.