Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Programming for All: AL Edition

Welcome to “Programming for All: AL Edition”, a guide to learning Application Language (AL).

Table of contents

Part I: Introduction to AL

Part II: Core Concepts of AL

Part III: AL for Business Central Development

Part IV: Best Practices and Optimization

Part V: Real-World Applications

Part VI: Appendices

About this guide

This guide teaches developers AL from the ground up, starting with the basic concepts and moving on to advanced features, best practices, and real-world applications. Each chapter combines explanations and practical examples with hands-on exercises that reinforce what you’ve learned.

Getting Started

To get started with the book, simply click on the links above to access each chapter. You can read the chapters directly on GitHub or clone the repository to your local machine to read offline.

Contributing

If you find any errors or have suggestions for improvement, feel free to open an issue or submit a pull request. Contributions are welcome and encouraged!

License

This book is licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License. You are free to share and adapt the content for non-commercial purposes, as long as you give appropriate credit and share your adaptations under the same license.

Acknowledgments

Special thanks to all contributors who have helped make this book possible.

Chapter 1: Understanding Application Language (AL)

Objectives

By the end of this chapter you will be able to:

  • Explain what AL is and where it runs in Business Central
  • Describe why extensions replaced direct base-application edits
  • Identify the main AL object types and what each one is responsible for
  • Trace how this book’s loan-repayment example will evolve across later chapters

1.1 What is AL?

Application Language (AL) is Microsoft’s language for building and extending Dynamics 365 Business Central. It is designed for ERP workflows, data validation, and process automation inside Business Central, and it isn’t a general-purpose desktop language.

With AL you define:

  • Data structures (tables)
  • User experiences (pages)
  • Business rules (codeunits and triggers)
  • Output and integration surfaces (reports, APIs, events)

1.2 From C/AL to AL: Why the model changed

Earlier Dynamics products used C/AL, and you customized them by editing the base objects directly. AL introduced an extension-first model, so your custom logic is packaged separately from Microsoft’s base app.

Extensions isolate your changes from core product code and reduce upgrade risk. They also support clean deployment and versioning, and they let multiple partners work on one tenant more safely.

Chapter 11 comes back to this with app.json, package metadata, and permission sets.

1.3 AL object types and responsibilities

A common beginner mistake is to put all the logic in one object. In this book, each object has a clear job:

  • Table: schema and integrity rules
  • Page: data entry and user actions
  • Codeunit: reusable business procedures
  • Permission set: who can do what
  • Test codeunit: automated verification

The case study in Chapter 16 depends on this separation.

1.4 The running example for this book

From Chapter 7 to Chapter 16 you’ll build a Loan Repayment Management extension. The steps come in a deliberate order:

  1. Create table structure and constraints (Chapter 7)
  2. Add a list page and actions (Chapter 8)
  3. Move logic into codeunits (Chapter 9)
  4. Package and secure the extension (Chapters 11 and 15)
  5. Protect behavior with tests (Chapter 14)

You won’t write any code in this chapter. The aim is to understand why that architecture is the right one.

Chapter summary

  • AL is Business Central’s extension language for ERP data and workflows
  • Extension packaging replaced direct base-code customization for upgrade safety
  • Object responsibilities must be kept separate (table/page/codeunit/security/tests)
  • Later chapters build one coherent loan-repayment extension from end to end

Tasks

  1. Architecture mapping. Write one sentence each for what belongs in a table, page, and codeunit.
  2. Upgrade reasoning. Explain why extension packaging is safer than editing base objects directly.
  3. Continuity checkpoint. List the first three chapters where the Loan Repayment example is implemented (by chapter number and purpose).

Check your work: your answers should align with the object separation used in Chapters 7–9 and the end-to-end flow in Chapter 16.

Chapter 2: Setting Up Your AL Development Environment

Objectives

By the end of this chapter you will be able to:

  • Explain the minimum local prerequisites for AL development
  • Create a new AL project in Visual Studio Code
  • Configure a sandbox connection safely in launch.json
  • Validate your environment before writing extension logic

2.1 Core prerequisites

To develop in AL you need four things:

  1. Visual Studio Code
  2. The AL Language extension (from Microsoft)
  3. A Business Central sandbox environment
  4. A user account with permission to publish and debug

If any of these is missing, later chapters may fail for reasons that have nothing to do with your AL code.

2.2 Create and initialize a project

Run AL: Go! from the VS Code command palette to scaffold the extension project. It generates baseline files such as app.json, .vscode/launch.json, and object folders.

Once the files are generated, check that:

  • app.json has a unique app ID and a sensible name and version.
  • Your object ID range matches the training objects you plan to create (see Chapter 11).

2.3 Configure and validate launch.json

Point launch.json at your sandbox environment and tenant, then press F5 to run a first publish.

To keep these first runs safe, use a dedicated sandbox, not production, and confirm that the authentication settings are correct. If the connection fails, sort that out before you write any business logic.

2.4 Environment validation checklist

Before you move on to Chapter 3, check that:

  • The AL extension is installed and active.
  • Symbols download successfully.
  • The first publish works without object conflicts.
  • You can set and hit a basic breakpoint.

That way you won’t end up chasing “code bugs” that are really setup issues.

Chapter summary

In this chapter you:

  • Identified the tools, permissions, and environment access you need
  • Scaffolded an AL project with AL: Go!
  • Configured the sandbox launch settings and checked that publishing and debugging work
  • Put together an environment checklist to repeat before you start coding

Tasks

  1. Project bootstrap. Create a new AL project and verify app.json exists.
  2. Connection validation. Configure launch.json and complete one successful publish.
  3. Debug readiness. Set a breakpoint in any procedure and verify debugger attach works.
  4. Try it yourself. Document one environment failure you hit and how you resolved it.

Check your work: if Chapters 3, 7, and 9 later fail to run, re-run this chapter’s checklist before changing business logic.

Chapter 3: First Steps with AL

Objectives

By the end of this chapter you will be able to:

  • Create a minimal AL extension with a table, page, and codeunit
  • Explain the role of each object in the extension architecture
  • Publish and test your first end-to-end interaction
  • Use basic debugging to validate behavior before adding complexity

3.1 Build a minimal vertical slice

Your first AL project should contain one small, complete flow:

  • A table for data
  • A page to view and edit records
  • A codeunit procedure called from the page

Chapters 7 to 16 use the same architecture, and it keeps business logic from piling up in page triggers.

3.2 Keep logic placement intentional

From day one, follow this rule for where logic goes:

  • Table trigger: data integrity constraints
  • Codeunit procedure: reusable business logic
  • Page action: only the entry point that calls codeunit logic

Chapter 9 formalizes this pattern, and Chapter 14 tests it.

3.3 Publish, run, and inspect

After you publish with F5:

  1. Open your page in Business Central.
  2. Create sample data.
  3. Trigger your page action.
  4. Confirm that the resulting table changes are correct.

If something is wrong, look at what is happening in the debugger before you edit several files.

3.4 Basic debugging loop

Keep the debugging loop tight:

  1. Reproduce one issue.
  2. Place breakpoints at object boundaries (page action, then codeunit, then table trigger).
  3. Inspect Rec values before and after procedure calls.
  4. Change one thing, retest, and repeat.

With this habit in place, the Rec vs xRec topic in Chapter 9 is much easier to follow.

Chapter summary

In this chapter you:

  • Built and published a minimal extension slice
  • Practiced correct logic placement across table, page, and codeunit
  • Checked behavior by running the code instead of relying on assumptions
  • Set up a debugging workflow you can repeat in later chapters

Tasks

  1. Create a starter object set. Add one table, one page, and one codeunit.
  2. Wire an action. Add a page action that calls a codeunit procedure.
  3. Validate update flow. Confirm the called procedure changes data as expected.
  4. Try it yourself. Record one bug you found with a breakpoint and what value inspection revealed.

Check your work: your flow should resemble the Chapter 8→9 pattern where a page action invokes codeunit logic instead of embedding business rules inline.

Chapter 4: Data Types and Variables in AL

Objectives

By the end of this chapter you will be able to:

  • Choose appropriate AL primitive and complex data types
  • Declare variables with intent and clear scope
  • Avoid common type-related errors in business logic
  • Prepare strongly typed data handling for table and page work in Chapters 7 and 8

4.1 Primitive data types

AL primitive types include Integer, Decimal, Boolean, Text, Date, and DateTime. Pick the narrowest type that still matches the business meaning of the value. Money and rates belong in Decimal. Compact identifiers, such as document numbers, fit Code[n], and free-form descriptive fields fit Text[n].

4.2 Complex data types

Common complex types include:

  • Record for table rows
  • List and Dictionary for in-memory collections
  • Enums and options for constrained business states

Most business workflows in this book pass Record values into codeunit procedures, especially from Chapter 9 onward.

4.3 Variable declaration and scope

procedure CalculateInstallmentAmount(Principal: Decimal; InterestRate: Decimal): Decimal
var
    InterestAmount: Decimal;
begin
    InterestAmount := Principal * InterestRate;
    exit(Principal + InterestAmount);
end;

Prefer local variables inside procedures. Use a global variable only when procedures in the same object need to share its state.

4.4 Type-safety patterns you will reuse

  • Validate assumptions early with TestField or an explicit Error.
  • Avoid implicit conversions that hide precision or truncation issues.
  • Keep identifier types consistent across objects. For example, a loan number should be Code[20] everywhere.

These habits mean fewer defects later on in table triggers and codeunit logic.

4.5 Type selection flow (visual guide)

When you’re unsure which type to pick, follow this flow:

flowchart TD
    A[What kind of value?] --> B{Identifier with fixed pattern?}
    B -->|Yes| C["Use Code[n]"]
    B -->|No| D{Money or rate?}
    D -->|Yes| E[Use Decimal]
    D -->|No| F{Free-form human text?}
    F -->|Yes| G["Use Text[n]"]
    F -->|No| H{Date only or date+time?}
    H -->|Date only| I[Use Date]
    H -->|Date + time| J[Use DateTime]

Chapter summary

In this chapter you:

  • Reviewed the main AL primitive and complex types
  • Practiced declaring variables with a clear purpose and scope
  • Learned type-safety habits that prevent business-logic bugs later
  • Prepared for strongly typed table and codeunit development

Tasks

  1. Type review (starter set). Classify these fields and justify each choice in one line: Loan No., Installment No., Principal Amount, Paid, Due Date, Payment Date.
  2. Boundary practice. For Code[20], Text[100], and Decimal, write one valid and one invalid sample value for each type.
  3. Variable scope cleanup. Move at least two global variables into a local procedure scope where they are only used once.
  4. Consistent identifier typing. Check every place where Loan No. is used and ensure the type is consistently Code[20].
  5. Validation guard. Add explicit validation so Principal Amount and Interest Amount cannot be zero or negative before calculation.
  6. Try it yourself. Add one extra field of your choice (for example, External Reference) and decide whether it should be Code or Text, with reasoning.

Check your work: compare your answers with the Chapter 4 solutions, then confirm your type choices stay consistent in Chapters 7, 9, and 15.

Chapter 5: Operators and Expressions

Objectives

By the end of this chapter you will be able to:

  • Use arithmetic, comparison, and logical operators correctly in AL
  • Write readable expressions for business rules
  • Avoid precedence mistakes that silently change behavior
  • Prepare expression patterns used in table triggers and codeunits

5.1 Arithmetic operators in business logic

AL supports +, -, *, and / for numeric calculations.

procedure CalculateLineTotal(Quantity: Decimal; UnitPrice: Decimal; DiscountPct: Decimal): Decimal
begin
    exit((Quantity * UnitPrice) * (1 - DiscountPct));
end;

Use Decimal for money calculations to avoid rounding surprises.

5.2 Comparison and logical operators

Validation and branching are built on comparison operators (=, <>, <, >, <=, >=) and logical operators (and, or, not).

if (Amount > 0) and (DueDate <> 0D) then
    Message('Installment is valid for posting.');

When you combine conditions, use explicit parentheses.

5.3 Expression clarity and maintainability

A long expression only counts as correct if future readers can audit it quickly. To keep yours easy to check:

  • Break complex formulas into named local variables.
  • Don’t duplicate the same calculation across procedures.
  • Make the business intent obvious, for example with an IsOverdue variable.

These choices make the performance work in Chapter 13 and the testing in Chapter 14 much easier.

5.4 Expression evaluation flow (visual guide)

Use this flow as a reminder of the evaluation order and where the validation checks happen:

flowchart TD
    A[Read inputs] --> B{Quantity > 0?}
    B -->|No| C[Error: invalid quantity]
    B -->|Yes| D{UnitPrice >= 0?}
    D -->|No| E[Error: invalid unit price]
    D -->|Yes| F[Gross = Quantity * UnitPrice]
    F --> G[”Discounted = Gross * (1 - DiscountPct)”]
    G --> H[Return line total]

Chapter summary

In this chapter you:

  • Used arithmetic, comparison, and logical operators in realistic AL scenarios
  • Practiced writing expressions that are both correct and readable
  • Learned patterns that reduce hidden logic bugs in later chapters

Tasks

  1. Operator warmup. Write one AL example each for arithmetic, comparison, and logical operators using loan-installment values.
  2. Formula extraction. Replace one long inline expression with named local variables (GrossAmount, DiscountAmount, NetAmount).
  3. Precedence check. Evaluate the same formula once without parentheses and once with parentheses; explain the difference.
  4. Condition hardening. Rewrite one multi-condition if with explicit parentheses and positive naming (IsDue, HasAmount, etc.).
  5. Validation expression. Add one expression that rejects invalid installment input (Amount <= 0, missing date, or invalid status combination).
  6. Try it yourself. Create a boolean expression for “ready to post” that uses at least three conditions and document why each is needed.

Check your work: compare your answers with the Chapter 5 solutions, then ensure your expressions remain reusable in Chapter 7 triggers and Chapter 9 codeunit procedures.

Chapter 6: Control Structures

Objectives

By the end of this chapter you will be able to:

  • Use if, case, and loops appropriately in AL
  • Choose between for, while, and repeat..until based on intent
  • Apply safe error handling patterns for business rules
  • Write control flow that scales to real data volumes

6.1 Conditional statements (if and case)

Use if for binary choices or short decision trees, and case when there are several discrete branches.

if Score >= 90 then
    Grade := 'A'
else
    if Score >= 80 then
        Grade := 'B'
    else
        Grade := 'C';
case DocumentStatus of
    DocumentStatus::Open:
        HandleOpen();
    DocumentStatus::Released:
        HandleReleased();
else
    Error('Unsupported status: %1', Format(DocumentStatus));
end;

6.2 Looping constructs

Choose the loop type that matches the business behavior:

  • for: known iteration count (for example, fixed installments)
  • while: condition-driven iteration
  • repeat..until: record traversal after FindSet
if Customer.FindSet() then
    repeat
        ProcessCustomer(Customer);
    until Customer.Next() = 0;

Chapters 9 and 13 use this pattern heavily.

6.3 Error handling and assertions

Use Error to stop invalid transactions early.

if Amount <= 0 then
    Error('Amount must be greater than zero.');

In tests (Chapter 14), prove behavior with assertions instead of checking it by hand in the UI.

6.4 Control-flow quality guidelines

  • Keep nesting shallow by extracting helper procedures.
  • Fail fast on invalid data.
  • Never use a loop when a filtered set would do (see Chapter 13).

6.5 Control-structure decision map (visual guide)

flowchart TD
    A[Need branching or iteration?] --> B{Single yes/no decision?}
    B -->|Yes| C[Use if]
    B -->|No| D{Multiple discrete states?}
    D -->|Yes| E[Use case]
    D -->|No| F{Known number of iterations?}
    F -->|Yes| G[Use for]
    F -->|No| H{Iterating record set after FindSet?}
    H -->|Yes| I[Use repeat..until]
    H -->|No| J[Use while]

Chapter summary

In this chapter you:

  • Applied AL conditionals and loops, with clear criteria for choosing each one
  • Used repeatable record-iteration patterns
  • Added safe, explicit error handling to business rules
  • Prepared control-flow techniques that codeunits and tests reuse

Tasks

  1. Branching choice. Replace one overgrown if chain with a case where appropriate.
  2. Decision fallback. Add an else branch (or case else) that raises an Error for unsupported state values.
  3. Loop correction. Refactor one loop to use FindSet + repeat..until for record traversal.
  4. Loop intent check. Convert one loop to for when the iteration count is known ahead of time.
  5. Guard clause. Add one fail-fast Error that protects invalid input before any data changes.
  6. Try it yourself. Extract one nested block into a helper procedure to reduce nesting depth.

Check your work: compare your answers with the Chapter 6 solutions, then verify your loop and branching style aligns with Chapters 9, 13, and 14 patterns.

Chapter 7: Creating and Managing Tables

Objectives

By the end of this chapter you will be able to:

  • Design a table with reliable keys and field constraints
  • Use TableRelation, NotBlank, and validation triggers correctly
  • Apply trigger logic that protects data integrity without blocking valid updates
  • Prepare a table structure that later chapters can build on safely

7.1 Defining tables in AL

Tables hold your extension’s source of truth. A good table design enforces rules close to the data instead of leaving them all to UI code.

table 50100 "Loan Repayment Schedule"
{
    DataClassification = CustomerContent;

    fields
    {
        field(1; "Loan No."; Code[20]) { DataClassification = CustomerContent; }
        field(2; "Installment No."; Integer) { DataClassification = CustomerContent; }
        field(3; "Vendor No."; Code[20]) { DataClassification = CustomerContent; TableRelation = Vendor."No."; }
        field(4; "Due Date"; Date) { DataClassification = CustomerContent; }
        field(5; "Payment Date"; Date) { DataClassification = CustomerContent; }
        field(6; "Principal Amount"; Decimal) { DataClassification = CustomerContent; }
        field(7; "Interest Amount"; Decimal) { DataClassification = CustomerContent; }
        field(8; "Total Amount"; Decimal) { DataClassification = CustomerContent; }
        field(9; Paid; Boolean) { DataClassification = CustomerContent; }
    }

    keys
    {
        key(PK; "Loan No.", "Installment No.") { Clustered = true; }
    }
}

7.2 Keys and indexing strategy

The primary key should reflect what makes a record unique in business terms. Here an installment number is unique only within its loan, so a composite key is appropriate.

Add secondary keys only for queries you actually run. Extra keys speed up reads but slow down writes.

7.3 Field properties and validation

Use field properties to move obvious rules out of procedural code:

  • NotBlank = true where empty values are invalid
  • TableRelation for foreign-key style lookups, like "Vendor No." above, which must match a record in the standard Vendor table
  • MinValue where negative values are impossible

That cuts down on duplicated checks in pages and codeunits.

7.4 Triggers for data integrity

Keep trigger logic minimal and deterministic.

trigger OnInsert()
begin
    "Total Amount" := "Principal Amount" + "Interest Amount";
end;

trigger OnModify()
begin
    if xRec.Paid then
        Error('A paid installment cannot be changed.');
    "Total Amount" := "Principal Amount" + "Interest Amount";
end;

Use xRec to check the previous state. Checking Rec here would block valid state transitions (Chapter 9 covers this in depth).

7.5 Table design practices

  • Keep derived values consistent (Total Amount from principal plus interest).
  • Leave side effects that belong in codeunits out of the table.
  • Set field classification and permissions to match Chapters 11 and 15.

Chapter summary

In this chapter you:

  • Designed a table with keys that reflect business meaning
  • Used properties and relations to enforce integrity
  • Implemented trigger logic with correct Rec/xRec semantics
  • Laid a table foundation for the page and codeunit work

Tasks

  1. Create the table. Implement Loan Repayment Schedule with composite key and core fields.
  2. Add constraints. Apply TableRelation, NotBlank, and value guards where needed.
  3. Harden triggers. Ensure OnModify checks xRec.Paid before allowing edits.
  4. Try it yourself. Add one additional field and justify its DataClassification.

Check your work: Chapter 9’s codeunit and Chapter 14’s tests should run against this table design without trigger-related regressions.

Chapter 8: Designing Pages and User Interfaces

Objectives

By the end of this chapter you will be able to:

  • Choose suitable page types for common AL scenarios
  • Build a list page over your Chapter 7 table
  • Add actions that delegate logic to codeunits
  • Keep UI code thin and maintainable

8.1 Page types and responsibilities

The common page types are:

  • Card: one record at a time
  • List: many records in a grid
  • Document: transactional workflows with a header and lines

For loan installments, a List page is the natural first UI.

8.2 Building a list page

page 50100 "Loan Repayment Schedule List"
{
    PageType = List;
    SourceTable = "Loan Repayment Schedule";
    ApplicationArea = All;

    layout
    {
        area(content)
        {
            repeater(Group)
            {
                field("Loan No."; Rec."Loan No.") { ApplicationArea = All; }
                field("Installment No."; Rec."Installment No.") { ApplicationArea = All; }
                field("Due Date"; Rec."Due Date") { ApplicationArea = All; }
                field(Paid; Rec.Paid) { ApplicationArea = All; }
            }
        }
    }
}

Rec is the page’s current record. Projects created with the AL: Go! command turn off the older implicit with, so a field’s source has to be written as Rec."Loan No.". A bare "Loan No." doesn’t compile there.

8.3 Actions and separation of concerns

Keep core rules out of actions. An action should call a procedure that holds them.

action(MarkPaid)
{
    ApplicationArea = All;
    Caption = 'Mark Paid';

    trigger OnAction()
    var
        LoanRepaymentMgt: Codeunit "Loan Repayment Mgt.";
    begin
        LoanRepaymentMgt.MarkInstallmentPaid(Rec);
        CurrPage.Update(false);
    end;
}

This keeps the page on the right side of the business-logic boundary that Chapter 9 sets up.

8.4 UI quality practices

  • Show the fields users need now, and leave out the clutter.
  • Keep captions explicit and in business terms.
  • Don’t duplicate table validation rules in the page unless users need the feedback earlier.

Chapter summary

In this chapter you:

  • Selected page types by how the business uses them
  • Implemented a list page over Chapter 7 data
  • Added thin actions that call codeunit logic
  • Prepared the UI layer for reuse and testing

Tasks

  1. Create the list page. Build a page for Loan Repayment Schedule.
  2. Add a business action. Add Mark Paid wired to a codeunit call.
  3. Review field set. Keep only fields required for day-to-day installment operations.
  4. Try it yourself. Add one UX improvement (sorting, grouping, or action placement) and justify it.

Check your work: your page action should remain thin and delegate business logic to Chapter 9 codeunit procedures.

Chapter 9: Developing Business Logic with Codeunits

Objectives

By the end of this chapter you will be able to:

  • Explain what a codeunit is and when logic belongs in one instead of a table or page trigger
  • Write public and local procedures, with parameters and return values
  • Call a codeunit from a page action
  • Recognize the Rec vs xRec distinction and why it matters in triggers
  • Apply codeunit best practices you can defend in a code review

9.1 What is a codeunit?

A codeunit is a container for procedures. It has no UI and no data structure of its own. It exists only to hold business logic, so that the logic can be reused, tested, and called from more than one place (a page action, another codeunit, a report, an API).

As a rule of thumb, logic that runs to more than a line or two, or that more than one object might need to call, belongs in a codeunit. Don’t duplicate it inside a table trigger or a page action.

codeunit 50100 "Loan Repayment Mgt."
{
    // Procedures go here
}

9.2 Public and local procedures

Procedures are public by default: any other object that has a variable of this codeunit’s type can call them. Mark a procedure local when it’s only a helper for other procedures in the same codeunit. That keeps the codeunit’s public surface small and easy to understand from the outside.

procedure MarkInstallmentPaid(var LoanRepaymentSchedule: Record "Loan Repayment Schedule")
begin
    LoanRepaymentSchedule."Payment Date" := Today;
    LoanRepaymentSchedule.Paid := true;
    LoanRepaymentSchedule.Modify(true);
end;

local procedure CalculateTotal(PrincipalAmount: Decimal; InterestAmount: Decimal): Decimal
begin
    exit(PrincipalAmount + InterestAmount);
end;

Hint: var before a parameter passes it by reference, so changes the procedure makes to LoanRepaymentSchedule are visible to the caller. Leave off var for a parameter you only want to read, like the amounts going into CalculateTotal.

9.3 A gotcha: Rec vs xRec

Chapter 7’s OnModify trigger blocks edits to a paid installment by checking xRec.Paid, not Paid. Here’s why. Suppose it checked if Paid then Error(...) instead. By the time OnModify runs, Rec already holds the new values you’re about to save, including the change from Paid = false to Paid = true that MarkInstallmentPaid is trying to make. So that trigger would block the switch to paid as well as any edits after it.

The trigger has to check the record’s state before this change, which AL gives you as xRec:

trigger OnModify()
begin
    if xRec.Paid then
        Error('A paid installment cannot be changed.');
    "Total Amount" := "Principal Amount" + "Interest Amount";
end;

Hint: Rec is “the record as it will be saved” and xRec is “the record as it was before this change.” Whenever a trigger needs to ask “did this field just change?” instead of “what is this field now?”, you need xRec. If your table from Chapter 7 checks Paid instead, fix it before you continue.

9.4 A procedure that creates multiple records

Some business logic changes more than one record. Generating a whole loan schedule up front means inserting several rows in a loop, and multi-step work like that doesn’t belong in a table trigger.

procedure GenerateSchedule(LoanNo: Code[20]; NumberOfInstallments: Integer; PrincipalPerInstallment: Decimal; InterestPerInstallment: Decimal; FirstDueDate: Date)
var
    LoanRepaymentSchedule: Record "Loan Repayment Schedule";
    i: Integer;
begin
    for i := 1 to NumberOfInstallments do begin
        LoanRepaymentSchedule.Init();
        LoanRepaymentSchedule."Loan No." := LoanNo;
        LoanRepaymentSchedule."Installment No." := i;
        LoanRepaymentSchedule."Due Date" := CalcDate(StrSubstNo('<+%1M>', i - 1), FirstDueDate);
        LoanRepaymentSchedule."Principal Amount" := PrincipalPerInstallment;
        LoanRepaymentSchedule."Interest Amount" := InterestPerInstallment;
        LoanRepaymentSchedule.Insert(true);
    end;
end;

Hint: Insert(true), unlike a plain Insert(), tells AL to run the table’s OnInsert trigger. That way "Total Amount" still gets calculated for every generated row, and you don’t have to repeat that logic here.

9.5 Calling a codeunit from a page

Business logic needs something to trigger it, and a page action is the most common entry point:

trigger OnAction()
var
    LoanRepaymentMgt: Codeunit "Loan Repayment Mgt.";
begin
    LoanRepaymentMgt.MarkInstallmentPaid(Rec);
    CurrPage.Update(false);
end;

9.6 Best practices for codeunits

  • Give each codeunit a single responsibility: one business area. "Loan Repayment Mgt." shouldn’t also contain vendor logic.
  • Keep triggers thin. A trigger should call a procedure instead of containing the whole calculation itself.
  • Check xRec to see what changed. Never assume Rec alone tells you the previous state.
  • Return simple values with exit, and change records through var parameters. Don’t mix the two patterns in one procedure.

Chapter summary

In this chapter you:

  • Learned when logic belongs in a codeunit instead of a trigger
  • Wrote a public procedure and a local helper procedure
  • Saw why Chapter 7’s trigger checks xRec and not Rec
  • Wrote a procedure that generates multiple records in a loop
  • Called a codeunit procedure from a page action

Tasks

These tasks continue the Loan Repayment Schedule example from Chapter 7.

  1. Check Chapter 7. Go back to your Loan Repayment Schedule table’s OnModify trigger and make sure it tests xRec.Paid, not Paid. Once MarkInstallmentPaid exists, confirm you can mark an installment paid without the trigger rejecting it.
  2. Create the codeunit. Create Codeunits/LoanRepaymentMgt.Codeunit.al with ID 50100 and name Loan Repayment Mgt..
  3. Add MarkInstallmentPaid. Add the public procedure from 9.2 that sets "Payment Date" and Paid, then calls Modify(true).
  4. Add GenerateSchedule. Add the procedure from 9.4 that inserts NumberOfInstallments rows for a given loan, spaced one month apart.
  5. Wire it to a page action. On your Loan Repayment Schedule list page (from Chapter 8), add an action that calls MarkInstallmentPaid on the selected record.
  6. Try it yourself. GenerateSchedule doesn’t check whether a schedule already exists for LoanNo before inserting new rows. What would happen if someone ran it twice for the same loan? Add whatever guard you think is needed.

Check your work: compare your codeunit against the reference solution.

Chapter 10: Reports and Data Analysis

Objectives

By the end of this chapter you will be able to:

  • Build AL reports with correctly scoped data items
  • Link parent/child data items safely
  • Apply practical filtering for useful report output
  • Design report logic that fits the table/codeunit architecture

10.1 Report structure in AL

A report has two parts: the dataset (the dataitem hierarchy) and the layout (RDLC or Word). Get the dataset right first. The layout comes second.

report 50110 "Loan Installment Summary"
{
    UsageCategory = ReportsAndAnalysis;
    ApplicationArea = All;

    dataset
    {
        dataitem(Installment; "Loan Repayment Schedule")
        {
            DataItemTableView = sorting("Loan No.", "Installment No.");
            column(LoanNo; "Loan No.") { }
            column(InstallmentNo; "Installment No.") { }
            column(DueDate; "Due Date") { }
            column(TotalAmount; "Total Amount") { }
            column(Paid; Paid) { }
        }
    }
}

10.2 Working with data items and filters

Use filters so a report doesn’t come out oversized or full of irrelevant rows. You can filter by loan number, date range, or paid status, and sort the rows in the order the business reads them. When you nest data items, keep the links between them explicit.

This follows the “filter first” performance principle from Chapter 13.

10.3 Layout and output guidance

RDLC is the stronger choice for operational reports that need pixel-perfect output. Word layouts can be easier for document-style output. Either way, keep business calculations in the AL dataset logic, not in ad-hoc formulas in the layout.

10.4 Reporting accuracy and maintainability

When a table already calculates and enforces a field, reuse that field where you can. Don’t derive the same value again, in a different way, in report-only code. Before you release a report, check sample output against the source records.

Chapter summary

In this chapter you:

  • Built a report dataset with clear business columns
  • Applied useful filters and sorting
  • Kept the report logic in line with the data and codeunit rules from earlier chapters
  • Chose a layout deliberately, based on the output the business needs

Tasks

  1. Create a report. Build a report over Loan Repayment Schedule showing due date and total amount.
  2. Add filters. Add at least one request filter (for example, Loan No. or Paid).
  3. Validate totals. Confirm reported Total Amount matches table-calculated values.
  4. Try it yourself. Add one grouped or summarized output (for example, total unpaid amount per loan).

Check your work: ensure your report reads the same business truth enforced by Chapters 7 and 9, not a duplicate formula.

Chapter 11: Extensions and Customizations

Objectives

By the end of this chapter you will be able to:

  • Explain what an AL extension is and why it’s preferred over modifying base code
  • Read and edit the main fields in app.json
  • Create a permission set that grants access to your own objects
  • Extend standard behavior with an event subscriber instead of changing base code
  • Apply extension-packaging best practices you can defend in a code review

11.1 What is an extension?

Customizing Business Central used to mean editing Microsoft’s own source code directly. That was risky, and it made upgrades painful. An extension is a separate package of AL objects (tables, pages, codeunits, and so on) that adds or changes behavior without touching the base application’s code.

Everything you’ve built so far already lives inside an extension: the Loan Repayment Schedule table, its page, and the Loan Repayment Mgt. codeunit. This chapter is about packaging that extension properly and extending other objects safely.

11.2 Anatomy of app.json

Every extension has an app.json manifest that describes what the extension is and what it depends on.

{
  "id": "b1f0c1a2-1234-4a1b-9c3d-1a2b3c4d5e6f",
  "name": "Loan Repayment Management",
  "publisher": "Your Company",
  "version": "1.0.0.0",
  "brief": "Track loan installments and repayment status.",
  "idRanges": [
    {
      "from": 50100,
      "to": 50149
    }
  ],
  "dependencies": [],
  "runtime": "12.0"
}

Hint: If an object ID falls outside the range declared in idRanges, the extension won’t compile. Reserve a range early and keep every object you create inside it.

11.3 Permission sets

Creating a table doesn’t automatically let anyone use it, because Business Central denies access by default. A permission set grants access to your objects as a named bundle that you can assign to users or roles.

permissionset 50100 "Loan Repayment"
{
    Assignable = true;
    Caption = 'Loan Repayment Management';

    Permissions =
        table "Loan Repayment Schedule" = X,
        tabledata "Loan Repayment Schedule" = RIMD,
        page "Loan Repayment Schedule List" = X,
        codeunit "Loan Repayment Mgt." = X;
}

Hint: Access to a table’s rows is granted on tabledata, with R, I, M, and D for read, insert, modify, and delete. X on a table, page, or codeunit only lets the user run or open that object; it doesn’t give access to any data. For a read-only reporting role, grant tabledata ... = R instead of RIMD. Don’t give everything full access just because it’s less to think about. That kind of blanket grant is exactly what a security review will flag.

11.4 Extending without modifying: event subscribers

Sometimes you want to react to something that happens in a standard object without touching that object. Microsoft publishes events for this, so you write a subscriber to the event instead of a modification.

codeunit 50101 "Loan Repayment Event Subscribers"
{
    [EventSubscriber(ObjectType::Table, Database::Vendor, 'OnAfterInsertEvent', '', false, false)]
    local procedure OnAfterInsertVendor(var Rec: Record Vendor)
    begin
        Message('New vendor %1 created — remember to check for outstanding loans.', Rec."No.");
    end;
}

Hint: The Message here is only for learning. In a real extension, a subscriber usually does something quieter, such as writing a log entry, creating a related record, or setting a default. A dialog popping up on every vendor insert would annoy every user in the company.

11.5 Best practices for extensions

  • Reserve your ID range once and stick to it. Mixing ranges across extensions is a common cause of deployment conflicts.
  • Grant the minimum permission needed: R on table data where read-only access is enough, and RIMD only where users must change data.
  • Prefer event subscribers to any form of base-object modification. Subscribers survive Microsoft’s updates, and modifications don’t.
  • Name each permission set so its purpose is obvious from the caption alone. “Loan Repayment Management” does that; “Custom Perms 1” doesn’t.

Chapter summary

In this chapter you:

  • Learned why extensions replaced direct changes to base code
  • Read the main fields of app.json and what they mean
  • Created a permission set that grants access to your own objects
  • Wrote an event subscriber that reacts to a standard table without modifying it

Tasks

  1. Write your app.json. Create one for the Loan Repayment extension with an idRanges block covering 50100–50149, and confirm every object you built in Chapters 7–9 falls inside it.
  2. Create the permission set. Add permissionset 50100 "Loan Repayment" granting RIMD on your table’s data and X on your table, page, and codeunit.
  3. Add an event subscriber. Subscribe to Vendor’s OnAfterInsertEvent (or another standard table’s insert event) and log or message something relevant to your extension.
  4. Try it yourself. Your permission set currently grants full RIMD access to the loan data. If you wanted a “read-only auditor” role that could view loan data but never change it, what would you change?

Check your work: compare your files against the reference solution in the Chapter 11 solutions.

Chapter 12: Integration with Other Systems

Objectives

By the end of this chapter you will be able to:

  • Expose controlled extension functionality by publishing a codeunit as a web service
  • Call external APIs with HttpClient and handle the response status properly
  • Parse and validate integration payloads before changing any data
  • Apply integration safeguards that support security and reliability

12.1 Exposing AL procedures as services

A codeunit can expose part of your extension’s functionality to external callers. Once the codeunit is published as a web service, each of its public procedures can be called as an OData V4 unbound action.

codeunit 50120 "Loan Integration API"
{
    procedure GetInstallmentStatus(LoanNo: Code[20]; InstallmentNo: Integer): Text
    var
        Installment: Record "Loan Repayment Schedule";
    begin
        if not Installment.Get(LoanNo, InstallmentNo) then
            Error('Installment %1/%2 was not found.', LoanNo, InstallmentNo);

        if Installment.Paid then
            exit('Paid');

        exit('Open');
    end;
}

To publish it, open the Web Services page in Business Central, add a line with object type Codeunit, object ID 50120, and a service name such as LoanIntegration, and select Published. An external system then calls the procedure with POST /ODataV4/LoanIntegration_GetInstallmentStatus, passing the parameters in the request body.

You don’t need an attribute on the procedure for this. The [ServiceEnabled] attribute is for a different case: a procedure on a page that is exposed as a web service, which becomes a bound action on that page’s records.

Return simple, stable values that external systems can depend on.

12.2 Calling external APIs safely

procedure GetCreditRiskScore(VendorNo: Code[20]): Decimal
var
    Client: HttpClient;
    Response: HttpResponseMessage;
    Content: Text;
begin
    Client.Get(StrSubstNo('https://api.example.com/vendors/%1/risk', VendorNo), Response);

    if not Response.IsSuccessStatusCode then
        Error('Risk API call failed with status %1.', Response.HttpStatusCode);

    Response.Content.ReadAs(Content);
    exit(EvaluateRiskScore(Content));
end;

Don’t show raw API responses to end users. Turn remote errors into clear messages that make sense in your business domain.

12.3 Payload validation and idempotency

Before you write external data to Business Central:

  • Validate required keys and value formats.
  • Reject unknown or unsupported status values.
  • Prevent duplicate inserts for the same external transaction key.

These checks protect data consistency. They are also good targets for the tests you’ll write in Chapter 14.

12.4 Integration design practices

  • Keep networking code in dedicated integration codeunits.
  • Separate parsing and validation from persistence logic.
  • Log enough context for support without leaking sensitive data.
  • Set permissions and data classifications as described in Chapters 11 and 15.

Chapter summary

In this chapter you:

  • Exposed controlled AL service operations
  • Made outbound HTTP calls that handle failure explicitly
  • Validated external payloads before changing data
  • Applied integration design patterns that hold up in production

Tasks

  1. Service endpoint. Create a codeunit procedure that returns installment status, and publish the codeunit as a web service.
  2. HTTP robustness. Implement one outbound API call that checks IsSuccessStatusCode and handles failures.
  3. Validation gate. Add payload validation that rejects missing or invalid required values.
  4. Try it yourself. Define how you would prevent the same external transaction from being imported twice.

Check your work: your integration flow should still honor table constraints from Chapter 7 and business-logic boundaries from Chapter 9.

Chapter 13: Performance Optimization

Objectives

By the end of this chapter you will be able to:

  • Explain why performance habits matter more in a multi-tenant SaaS system than they might in other software
  • Filter records before looping instead of after
  • Use SetLoadFields to avoid pulling columns you don’t need
  • Recognize and avoid database calls inside a loop
  • Apply performance best practices you can defend in a code review

13.1 Why performance habits matter

Business Central is a SaaS product. Your extension’s code runs alongside every other extension in the same tenant and shares the same database resources. A loop that’s “fine” on a test company with 50 records can time out on a customer’s production company with 500,000. So performance is something you build into the first draft as a habit, instead of leaving it for an optimization pass late in the project.

13.2 Filter before you loop

The most common mistake is to read every record and then check a condition in AL code, when you could have asked the database to filter first.

// Slow: reads every row, checks Paid in AL for each one
LoanRepaymentSchedule.FindSet();
repeat
    if not LoanRepaymentSchedule.Paid then
        // ...
until LoanRepaymentSchedule.Next() = 0;
// Fast: the database only returns unpaid rows
LoanRepaymentSchedule.SetRange(Paid, false);
if LoanRepaymentSchedule.FindSet() then
    repeat
        // ...
    until LoanRepaymentSchedule.Next() = 0;

Hint: SetRange/SetFilter always go before FindSet. Calling them afterward has no effect on a find that already ran.

13.3 SetLoadFields: only ask for what you use

By default, AL loads every field of a record, even if your code reads only two of them. On a wide table, that’s wasted work on every row.

LoanRepaymentSchedule.SetLoadFields("Loan No.", "Due Date", Paid);
LoanRepaymentSchedule.SetRange(Paid, false);
if LoanRepaymentSchedule.FindSet() then
    repeat
        // only "Loan No.", "Due Date", and Paid are populated here
    until LoanRepaymentSchedule.Next() = 0;

Hint: If you call SetLoadFields and then read a field you didn’t list, AL fetches it with a just-in-time load: another database read for that record. Inside a loop, that’s an extra read on every row, and it fails with an error if someone else changed or deleted the record in the meantime. List every field your loop body touches.

13.4 Don’t call the database inside a loop

A Get(), .Insert(), or another record’s FindSet() inside a loop multiplies your database round-trips by the number of iterations.

// Slow: one database read per installment, inside the loop
LoanRepaymentSchedule.SetRange("Loan No.", LoanNo);
if LoanRepaymentSchedule.FindSet() then
    repeat
        Loan.Get(LoanRepaymentSchedule."Loan No.");  // repeated, but Loan No. never changes here
        // ...
    until LoanRepaymentSchedule.Next() = 0;
// Fast: read the loan once, outside the loop
Loan.Get(LoanNo);
LoanRepaymentSchedule.SetRange("Loan No.", LoanNo);
if LoanRepaymentSchedule.FindSet() then
    repeat
        // Loan is already available here
    until LoanRepaymentSchedule.Next() = 0;

13.5 Best practices for performance

  • Always filter first: call SetRange/SetFilter before FindSet, and never check a condition and skip rows inside the loop.
  • Use SetLoadFields on any loop over a table with more than a handful of fields, especially in reports and background jobs over large tables.
  • Hoist anything that stays constant across iterations out of the loop. A Get() that doesn’t depend on the loop variable doesn’t belong inside it.
  • Before you call something “done,” test it against realistic data volumes, not just your five-row demo company.

Chapter summary

In this chapter you:

  • Learned why SaaS multi-tenancy makes performance habits matter more
  • Filtered with SetRange/SetFilter before looping, not after
  • Used SetLoadFields to avoid loading unused columns
  • Learned to hoist unchanging lookups out of a loop

Tasks

  1. Filter GenerateSchedule’s caller. Write a procedure that loops over all unpaid installments across every loan and prints a count. Use SetRange(Paid, false) before FindSet, not an if inside the loop.
  2. Add SetLoadFields. To the procedure from task 1, add SetLoadFields listing only the fields you actually read.
  3. Find the hoist. Imagine a procedure that, for every installment, looks up the loan’s borrower name from a Loan header table. If the procedure only ever processes installments from a single loan at a time, where should that lookup happen — inside or outside the loop? Rewrite the pseudocode to put it in the right place.
  4. Try it yourself. GenerateSchedule from Chapter 9 inserts records one at a time with Insert(true). For very large installment counts, would you change anything about how it writes records? What would you check before deciding that’s worth doing?

Check your work: compare your procedures against the reference solution in the Chapter 13 solutions.

Chapter 14: Testing and Debugging

Objectives

By the end of this chapter you will be able to:

  • Explain what a test codeunit is and how it differs from a normal one
  • Write a test method using the AAA pattern (Arrange, Act, Assert)
  • Use the Assert codeunit to verify expected results
  • Set a breakpoint and step through code with the VS Code debugger
  • Apply testing best practices you can defend in a code review

14.1 What is a test codeunit?

A test codeunit is a normal codeunit with Subtype = Test that contains methods marked [Test]. The test runner calls each [Test] method. Each one either passes silently or fails with a message that points at exactly what went wrong. You get that feedback far faster than by clicking through the UI by hand after every change.

codeunit 50102 "Loan Repayment Mgt. Tests"
{
    Subtype = Test;

    [Test]
    procedure MarkInstallmentPaidSetsPaymentDate()
    begin
        // test body goes here
    end;
}

14.2 Arrange, Act, Assert

Every test method has the same three-part shape: set up the data you need (Arrange), call the code under test (Act), then check the result (Assert).

[Test]
procedure MarkInstallmentPaidSetsPaymentDate()
var
    LoanRepaymentSchedule: Record "Loan Repayment Schedule";
    LoanRepaymentMgt: Codeunit "Loan Repayment Mgt.";
    Assert: Codeunit Assert;
begin
    // Arrange
    LoanRepaymentSchedule.Init();
    LoanRepaymentSchedule."Loan No." := 'LOAN0001';
    LoanRepaymentSchedule."Installment No." := 1;
    LoanRepaymentSchedule."Principal Amount" := 1000;
    LoanRepaymentSchedule."Interest Amount" := 50;
    LoanRepaymentSchedule.Insert(true);

    // Act
    LoanRepaymentMgt.MarkInstallmentPaid(LoanRepaymentSchedule);

    // Assert
    Assert.IsTrue(LoanRepaymentSchedule.Paid, 'Installment should be marked paid.');
    Assert.AreEqual(Today, LoanRepaymentSchedule."Payment Date", 'Payment date should be today.');
end;

Hint: Assert is codeunit 130000 from Microsoft’s test libraries. Declare a variable for it like any other codeunit, and list the test libraries as dependencies in your test app’s app.json. Assert.AreEqual takes the expected value first and the actual value second. Getting this backwards doesn’t break the test, but it makes failure messages confusing to read later.

14.3 A regression test for the xRec check

A regression test is written specifically so that a fixed bug can never silently come back. Chapter 9 showed that an OnModify trigger checking Paid instead of xRec.Paid would block the transition to paid. That rule is worth locking in with a test.

[Test]
procedure MarkingPaidDoesNotTriggerBlockedEditError()
var
    LoanRepaymentSchedule: Record "Loan Repayment Schedule";
    LoanRepaymentMgt: Codeunit "Loan Repayment Mgt.";
    Assert: Codeunit Assert;
begin
    // Arrange
    LoanRepaymentSchedule.Init();
    LoanRepaymentSchedule."Loan No." := 'LOAN0001';
    LoanRepaymentSchedule."Installment No." := 1;
    LoanRepaymentSchedule.Insert(true);

    // Act — this should succeed, not raise the "cannot be changed" error
    LoanRepaymentMgt.MarkInstallmentPaid(LoanRepaymentSchedule);

    // Assert
    Assert.IsTrue(LoanRepaymentSchedule.Paid, 'Marking paid should succeed without error.');
end;

Hint: If someone later changes the trigger to check Paid, this test fails straight away and catches the Rec/xRec bug before it ever reaches a user. Writing the test as soon as you fix a bug is what keeps the bug fixed.

14.4 Debugging with breakpoints

Not every problem is worth writing a test for first. Sometimes you just need to see what’s happening. In VS Code, click in the gutter to the left of a line number to set a breakpoint, then launch with debugging (F5). Execution pauses at that line. From there you can inspect variable values, step line by line (F10), or step into a called procedure (F11).

Hint: Set the breakpoint on the first line inside the loop or procedure you suspect, not somewhere further down. You want to catch the state as early as possible, before it has had a chance to go wrong.

14.5 Best practices for testing and debugging

  • Keep to one assertion concept per test. A test named MarkInstallmentPaidSetsPaymentDate shouldn’t also silently check three unrelated things.
  • Name tests after what they verify, not after the procedure they call. When a test fails later, its name tells you what was being checked.
  • Write a regression test as soon as you fix a bug, not “later.”
  • Reach for a breakpoint before you add Message() calls everywhere. Messages linger in the code after you’re done debugging, and breakpoints don’t.

Chapter summary

In this chapter you:

  • Wrote a test codeunit with Subtype = Test
  • Structured a test with Arrange, Act, Assert
  • Used Assert.IsTrue and Assert.AreEqual
  • Wrote a regression test for the xRec check from Chapter 9
  • Learned how to set a breakpoint and step through code

Tasks

  1. Create the test codeunit. Create Codeunits/LoanRepaymentMgtTests.Codeunit.al with Subtype = Test.
  2. Write the paid-date test. Add MarkInstallmentPaidSetsPaymentDate from 14.2.
  3. Write the regression test. Add MarkingPaidDoesNotTriggerBlockedEditError from 14.3, and confirm it passes against your current table.
  4. Break it on purpose. Temporarily change xRec.Paid to Paid in your table’s OnModify trigger, rerun the test, and confirm it now fails. Then put xRec.Paid back.
  5. Try it yourself. Write a third test for GenerateSchedule: given NumberOfInstallments = 3, assert that exactly 3 records exist for the loan afterward.

Check your work: compare your test codeunit against the reference solution in the Chapter 14 solutions.

Chapter 15: Security and Compliance

Objectives

By the end of this chapter you will be able to:

  • Explain what DataClassification communicates and why it isn’t optional
  • Audit an existing table’s fields for correct classification
  • Scope a permission set to the minimum access a role actually needs
  • Distinguish object-level permission from field-level sensitivity
  • Apply security best practices you can defend in a code review

15.1 DataClassification is a compliance signal, not decoration

Every field you’ve written since Chapter 7 has had a DataClassification. For privacy and compliance purposes, that property tells Business Central (and anyone auditing your extension) what kind of data the field holds:

  • CustomerContent for business data that belongs to the customer
  • EndUserIdentifiableInformation for anything that identifies a specific person
  • SystemMetadata for technical housekeeping fields

Setting every field to ToBeClassified and moving on defeats the purpose. It’s the field-level equivalent of leaving a form blank.

15.2 Auditing Loan Repayment Schedule

Here are the fields from Chapter 7, reviewed with compliance in mind:

FieldClassificationWhy
"Loan No."CustomerContentBusiness data, not personal
"Installment No."CustomerContentBusiness data
"Due Date" / "Payment Date"CustomerContentBusiness data
"Principal Amount" / "Interest Amount" / "Total Amount"CustomerContentFinancial business data
PaidCustomerContentBusiness data

None of these fields identify a specific person, so CustomerContent across the board is defensible here. Suppose you later added a "Borrower Name" field directly to this table instead of looking the name up from a Customer or Loan record. That field would need EndUserIdentifiableInformation, which is one more reason, besides normalization, to keep personal data in its own table.

Hint: When in doubt, ask: “If this field’s value leaked, would it identify a real person?” If yes, it’s EndUserIdentifiableInformation, not CustomerContent.

15.3 Scoping a permission set to a role, not “everyone”

Chapter 11 granted full access (RIMD on the table data and X on every object) in one permission set. In practice, different roles need different access:

permissionset 50103 "Loan Repayment - View"
{
    Assignable = true;
    Caption = 'Loan Repayment (View Only)';

    Permissions =
        table "Loan Repayment Schedule" = X,
        tabledata "Loan Repayment Schedule" = R,
        page "Loan Repayment Schedule List" = X;
}

permissionset 50104 "Loan Repayment - Full"
{
    Assignable = true;
    Caption = 'Loan Repayment (Full Access)';
    IncludedPermissionSets = "Loan Repayment - View";

    Permissions =
        tabledata "Loan Repayment Schedule" = IMD,
        codeunit "Loan Repayment Mgt." = X;
}

Hint: IncludedPermissionSets lets the “Full” set build on top of “View” instead of repeating its permissions. It also means that a later change to what “View” grants applies automatically everywhere “View” is included, so you have one set to update instead of several.

15.4 Field-level considerations

Object-level permissions (table, page, codeunit) control whether a user can open something at all. On their own, they don’t hide specific sensitive fields from a user who has table access. For that you need page design decisions on top of correct DataClassification: leave the field off a page entirely, or make it visible only to certain roles through a page extension.

15.5 Best practices for security and compliance

  • Classify each field honestly. Don’t blanket-set a classification on the table, and never leave a field at ToBeClassified in anything you ship.
  • Design permission sets around roles, not around “give this person what they’re asking for right now.”
  • Build layered permission sets with IncludedPermissionSets instead of repeating the same grants across several sets.
  • Treat page design as part of security, along with permissions. How a page is built can keep a user who has table access away from a sensitive field.

Chapter summary

In this chapter you:

  • Learned to treat DataClassification as a compliance signal, not boilerplate
  • Audited the classifications on the Loan Repayment Schedule table
  • Split one broad permission set into role-scoped ones
  • Learned the difference between object-level and field-level security

Tasks

  1. Audit your table. Go through every field on Loan Repayment Schedule and confirm its DataClassification matches the table in 15.2. Fix any that don’t.
  2. Split the permission set. Replace the single permission set from Chapter 11 with the “View” and “Full” pair from 15.3.
  3. Assign roles. Decide which of your test users would get “View” and which would get “Full,” and write one sentence justifying each.
  4. Try it yourself. Suppose a future chapter adds a "Borrower Name" field directly onto Loan Repayment Schedule (skipping the lookup to a Loan table). What DataClassification would it need, and what page-design decision would you make to limit who sees it?

Check your work: compare your permission sets against the reference solution in the Chapter 15 solutions.

Chapter 16: ERP Case Studies and Examples

Objectives

By the end of this chapter you will be able to:

  • See how the individual AL objects you built in Chapters 7 to 15 form one coherent extension
  • Trace a single business event through every layer: table, page, codeunit, permissions, tests
  • Evaluate a second, unfamiliar case study using the same design questions
  • Plan your own small extension from a business problem, not from a list of AL features

16.1 Case study: Loan Repayment Management, end to end

Here is everything you built, in the order a request flows through it:

  1. A purchasing or finance user opens the page (Chapter 8) built on Loan Repayment Schedule.
  2. The table (Chapter 7) enforces that every row has a valid "Vendor No."-style relation, a composite key of loan + installment, and a correct "Total Amount". The amount is always right because the table’s own triggers calculate it, not the page.
  3. Clicking “Mark Paid” runs a page action that calls LoanRepaymentMgt.MarkInstallmentPaid (Chapter 9). The logic isn’t written inline on the page, so the same rule applies no matter what calls it.
  4. That codeunit’s logic is protected by a test (Chapter 14) that would fail loudly if a future change reintroduced the Rec/xRec bug from Chapter 9.
  5. Only users who hold the right permission set (Chapters 11 and 15) can open the page or run the codeunit at all, and a “View Only” role can see schedules without being able to change them.
  6. Every field on the table carries a DataClassification (Chapter 15) that would hold up in a compliance review.

No single chapter’s topic (tables, pages, codeunits, tests, or permissions) does the whole job. The extension works only because the pieces fit together.

Hint: Here is a way to check that you understood a chapter’s topic and didn’t just copy its code. Can you point to the one line in a different chapter’s object that depends on it? If you can’t, reread the chapter before moving on.

16.2 A second case study: Petty Cash Log

Here is a new, smaller problem. Work through the same design questions yourself before you read the suggested answers:

A small office wants to log petty cash disbursements: who received cash, how much, for what, and on what date. Someone should be able to mark an entry as “reconciled” once it’s been checked against a physical receipt, and reconciled entries shouldn’t be editable.

Ask yourself:

  • What table(s) do you need? Is one table enough, or does “who received cash” imply a relation to an existing table (like Employee)?
  • What would the composite or single-field primary key be?
  • Which field(s) need a MinValue, NotBlank, or TableRelation?
  • What trigger enforces “reconciled entries can’t be edited,” and does it need the Rec/xRec lesson from Chapter 9?
  • What single procedure in a codeunit should “mark reconciled” call through, rather than being written directly on a page action?
  • What would a “View Only” vs “Full Access” permission set look like for this?

Try it yourself: Before you look at the suggested design below, sketch your own table definition for Petty Cash Log on paper or in a scratch .al file.

A reasonable design, for comparison: a Petty Cash Log table with an auto-increment "Entry No." primary key. The key isn’t composite: there’s no natural grouping field the way loans group installments. The table also has a "Received By" field with TableRelation = Employee."No.", plus Amount, Purpose, "Entry Date", and Reconciled (Boolean). The OnModify trigger checks xRec.Reconciled, the same way the loan table checks xRec.Paid. A "Petty Cash Mgt." codeunit exposes MarkReconciled, and a page action calls it. The logic is never written inline on the page.

16.3 Planning your own extension

The habit to take from these case studies is to start from the business problem and the questions in 16.2, not from “which AL features should I use.” Answer those questions first, and the table design follows from the answers.

Chapter summary

In this chapter you:

  • Traced how every AL object type from Chapters 7 to 15 contributes to one working extension
  • Worked through a second case study using the same design questions, before seeing a suggested answer
  • Came away with a repeatable set of questions for starting any new extension from a business problem

Tasks

  1. Diagram your extension. Draw (on paper or in any tool) the six steps from 16.1 as boxes and arrows, labeling which AL object handles each step.
  2. Build Petty Cash Log. Implement the table, codeunit, permission sets, and at least one test for the case study in 16.2, using Chapters 7–15 as your reference — but write the code yourself.
  3. Try it yourself. Petty Cash Log’s suggested design uses a single auto-increment key instead of a composite one like Loan Repayment Schedule. Write one sentence explaining why a composite key wasn’t the right choice here.

Check your work: compare your Petty Cash Log objects against the reference solution in the Chapter 16 solutions.

Chapter 17: Advanced AL Features

Objectives

By the end of this chapter you will be able to:

  • Implement publisher/subscriber event patterns with clear responsibility boundaries
  • Design extensibility points without hard-coding dependencies
  • Use interfaces and dependency inversion for testable, modular AL code
  • Evaluate advanced patterns against maintainability and upgrade safety

17.1 Eventing with publishers and subscribers

Events let you extend behavior without editing base logic directly.

codeunit 50130 "Loan Events"
{
    [IntegrationEvent(false, false)]
    procedure OnInstallmentMarkedPaid(LoanNo: Code[20]; InstallmentNo: Integer)
    begin
    end;
}
codeunit 50131 "Loan Notifications"
{
    [EventSubscriber(ObjectType::Codeunit, Codeunit::"Loan Events", 'OnInstallmentMarkedPaid', '', false, false)]
    local procedure HandleInstallmentMarkedPaid(LoanNo: Code[20]; InstallmentNo: Integer)
    begin
        // Send notification, write telemetry, or enqueue follow-up work
    end;
}

This pattern builds on the extension concepts from Chapter 11 in a way that is safe for production.

17.2 Interfaces and dependency inversion

Use interfaces when more than one implementation is possible.

interface "IRiskScoringProvider"
{
    procedure GetRiskScore(VendorNo: Code[20]): Decimal;
}
codeunit 50132 "Default Risk Scoring Provider" implements "IRiskScoringProvider"
{
    procedure GetRiskScore(VendorNo: Code[20]): Decimal
    begin
        exit(0);
    end;
}

The calling code stays the same while the implementations change.

17.3 Advanced pattern selection criteria

Use advanced features only when they solve a real problem:

  • Event subscribers for extension and decoupling
  • Interfaces for replaceable strategies
  • Single-instance codeunits only when you truly need state shared within a session

Overusing them in simple scenarios adds maintenance cost.

17.4 Testing advanced behavior

Advanced patterns need tests too:

  • Verify that subscribers run only on the events they are meant for.
  • Validate interface implementations against consistent contracts.
  • Make sure fallback behavior exists for when optional integrations are unavailable.

This is the same regression mindset as in Chapter 14.

Chapter summary

In this chapter you:

  • Implemented event-driven extension points that keep code safely decoupled
  • Applied interface-based design for replaceable behavior
  • Weighed when advanced AL patterns are justified
  • Tied advanced design back to testability and upgrade safety

Tasks

  1. Publish an event. Add an integration event to your loan-management flow.
  2. Subscribe responsibly. Create one subscriber codeunit that reacts without duplicating core logic.
  3. Introduce an interface. Define one interface and one implementation for a replaceable service.
  4. Try it yourself. Explain one case where a simple direct call is better than an event + subscriber chain.

Check your work: advanced patterns should extend Chapter 9 logic and Chapter 11 packaging without weakening clarity or testability.

Chapter 18: Contributing to the AL Community

Objectives

By the end of this chapter you will be able to:

  • Contribute meaningful improvements to AL repositories
  • Write contribution artifacts that are technically useful, beyond cosmetic fixes
  • Collaborate through issues, pull requests, and review feedback
  • Evaluate contribution quality against clear standards

18.1 Contributing to open source AL projects

The most useful contributions are usually:

  • bug fixes with steps to reproduce the bug,
  • tests that prevent regressions,
  • performance or security improvements,
  • documentation updates tied to how the code behaves.

An open source AL project you can contribute to:

  • BCApps, Microsoft’s repository for the Business Central application platform: the System Application, the Base Application, and Microsoft’s first-party apps

18.2 Pull request quality standards

A strong contribution should include:

  1. Problem statement: what was wrong and why it matters.
  2. Change summary: what was modified and where.
  3. Validation evidence: tests, repro output, or before/after behavior.
  4. Risk notes: known limitations or follow-up tasks.

Avoid low-impact PRs that only fix an isolated spelling mistake and add no technical value.

18.3 Writing helpful documentation and tutorials

Write documentation that a developer can act on, not only read:

  • Use runnable examples.
  • Explain assumptions and prerequisites.
  • Link related chapters or objects so readers have the context.

Chapters 9, 11, and 16 of this book are good models of this structure.

18.4 Community collaboration habits

  • Open a clear issue before making large design changes.
  • Respond to review feedback with evidence, not opinion.
  • Keep discussions respectful and focused on solutions.

Chapter summary

In this chapter you:

  • Learned how to make AL contributions that are technically meaningful
  • Applied concrete quality criteria to pull requests
  • Practiced documentation patterns that support implementation
  • Built contribution habits that keep community collaboration maintainable

Tasks

  1. Contribution assessment. Review one AL PR and score it against the four quality standards in 18.2.
  2. Improvement proposal. Draft a high-value issue that includes reproducible steps and expected behavior.
  3. Documentation task. Update one technical section so another contributor can execute it without extra context.
  4. Try it yourself. Write one paragraph explaining why test evidence is more persuasive than screenshots alone.

Check your work: your contribution checklist should be specific enough to evaluate this book’s chapter tasks and reference solutions.

Appendix A: AL Syntax Reference

Objectives

By the end of this appendix you will be able to:

  • Quickly look up common AL syntax and object patterns
  • Reuse safe, production-oriented code templates
  • Avoid beginner syntax mistakes that break compilation or runtime behavior
  • Cross-reference syntax patterns used throughout Chapters 7 to 17

A.1 Core syntax cheatsheet

Object and procedure scaffolding

codeunit 50100 "Example"
{
    procedure DoWork(InputValue: Integer): Integer
    var
        Result: Integer;
    begin
        Result := InputValue + 1;
        exit(Result);
    end;
}

Conditionals and loops

if IsValid then
    Process()
else
    Error('Validation failed.');

if Rec.FindSet() then
    repeat
        HandleRec(Rec);
    until Rec.Next() = 0;

A.2 Common AL patterns (practical)

Record filtering before iteration

Installment.SetRange(Paid, false);
if Installment.FindSet() then
    repeat
        ProcessInstallment(Installment);
    until Installment.Next() = 0;

Safe state transition in trigger logic

trigger OnModify()
begin
    if xRec.Paid then
        Error('A paid installment cannot be changed.');
end;

Event subscriber scaffold

[EventSubscriber(ObjectType::Table, Database::Vendor, 'OnAfterInsertEvent', '', false, false)]
local procedure OnAfterInsertVendor(var Rec: Record Vendor)
begin
    // reaction logic
end;

A.3 Frequent syntax pitfalls

  • Using = instead of := for assignment
  • Forgetting begin/end for multi-line blocks
  • Calling FindSet before SetRange
  • Checking Rec when prior state requires xRec
  • Mixing field names with inconsistent casing or quoting

A.4 Quick lookup table

NeedRecommended AL construct
Reusable business ruleCodeunit procedure
Data integrity at sourceTable trigger + field properties
User-triggered workflowPage action calling codeunit
Extension reaction pointPublisher/subscriber events
Regression safetyTest codeunit + assertions

Appendix summary

You now have a compact syntax reference for daily AL work and a set of practical patterns that match this book’s architecture. You can also spot and avoid common syntax and flow mistakes.


Tasks

  1. Pattern matching. Pick two chapter snippets and map each to a pattern in A.2.
  2. Pitfall review. Check one of your objects for at least two A.3 pitfalls.
  3. Try it yourself. Add one personal syntax snippet to your local notes for future reuse.

Check your work: your selected patterns should align with Chapters 7, 9, 11, 14, and 17 practices.

Appendix B: Troubleshooting and FAQs

Objectives

By the end of this appendix you will be able to:

  • Diagnose common AL development failures faster
  • Use a repeatable debug checklist before changing code
  • Classify errors by layer (environment, compile, runtime, data)
  • Apply issue-resolution habits that improve contribution quality

B.1 Debug checklist (use in order)

  1. Environment check: the AL extension for VS Code is installed, symbols are downloaded, and the sandbox is reachable.
  2. Compile check: resolve the first error in the output before chasing the errors that follow from it.
  3. Repro check: reproduce the problem with the fewest steps and known sample data.
  4. Boundary check: inspect each transition from page action to codeunit to table trigger.
  5. Data check: verify key fields and your assumptions about prior state (xRec).
  6. Regression check: add or run tests for the fixed behavior (Chapter 14).

B.2 Common error catalog

Error symptomLikely causeFirst fix step
Table ... not foundMissing symbol reference or wrong object ID/rangeRe-download symbols and verify object metadata
The record already existsDuplicate key on insertValidate key design and duplicate guards
You do not have permission ...Missing permission set grantsReview Chapter 11/15 permission setup
Runtime cannot be changed unexpectedlyTrigger checks current state (Rec) instead of prior state (xRec)Correct trigger logic and add regression test
API call fails with non-success statusRemote endpoint/auth/contract mismatchLog status code and validate request contract

B.3 FAQ

How do I know whether logic belongs in a page trigger or codeunit?

Put reusable business rules in a codeunit and call them from page actions. Keep page code thin (Chapters 8 and 9).

Why does my trigger block valid edits?

Check whether your rule needs the pre-change state (xRec) instead of the current state (Rec) in OnModify.

When should I add tests?

Right after you add or fix business logic, and especially after a bug fix that could regress (Chapter 14).

B.4 Escalation notes for contributors

When you open an issue or PR, include minimal repro steps, the observed and expected behavior, a link to the affected chapter or object, and evidence that you validated the change.

Appendix summary

You now have a practical troubleshooting sequence, and you can quickly map common errors to their likely causes. You can also package technical findings clearly so others can work with them.


Tasks

  1. Checklist run. Apply B.1 to one recent issue you encountered.
  2. Error mapping. Add one new row to the error catalog from your own experience.
  3. Try it yourself. Write a minimal repro template for future chapter bugs.

Check your work: your troubleshooting notes should be detailed enough for another contributor to reproduce and validate your fix.

Appendix C: Resources and Further Reading

Objectives

By the end of this appendix you will be able to:

  • Use a curated learning path based on your AL maturity level
  • Select resources by skill goal (syntax, architecture, testing, integration)
  • Build an ongoing practice routine using this book and external references
  • Plan how to grow as a contributor, from learner to reviewer

C.1 Toolchain setup matrix

GoalPrimary toolWhy it matters
Author AL objectsVisual Studio Code + AL extensionCore development workflow
Publish/debug extensionBusiness Central sandbox + debuggerSafe runtime validation
Validate behaviorAL test codeunitsRegression protection
CollaborateGitHub issues/PRsShared review and improvement loop

C.2 Curated learning path

Beginner track

  1. Chapters 1 to 3 (foundation and setup)
  2. Chapters 4 to 6 (language basics)
  3. Chapters 7 to 9 (table/page/codeunit architecture)

Intermediate track

  1. Chapter 10 (reporting)
  2. Chapter 11 (packaging and permissions)
  3. Chapter 12 (integration patterns)
  4. Chapters 13 to 15 (performance, testing, security)

Advanced/contributor track

  1. Chapter 16 (case-study synthesis)
  2. Chapter 17 (advanced extensibility patterns)
  3. Chapter 18 (quality contributions)

C.4 Practice and contribution routine

  • Weekly: implement one chapter task end to end.
  • Biweekly: add or improve one regression test.
  • Monthly: submit one contribution with validation evidence.
  • Quarterly: review one external AL repository and write down what you learned.

Appendix summary

You now have a map of resources tied to concrete AL goals and a staged learning plan that takes you from beginner to contributor. The routine in C.4 gives you a regular practice schedule to keep improving.


Tasks

  1. Path selection. Choose a track and list your next three chapters.
  2. Resource mapping. Pair one external resource with each selected chapter.
  3. Try it yourself. Draft a 4-week practice plan using C.4 cadence.

Check your work: your plan should include implementation, validation, and contribution activity—not reading alone.

Solutions Index

This section collects the reference solutions for the chapters whose Check your work notes point here.

Chapters Covered

  • Chapter 4 — data-type selection, variable-scope cleanup, and validation-guard worked answers.
  • Chapter 5 — operator/expression practice with readability and validation-focused solutions.
  • Chapter 6 — control-structure selection, loop patterns, and fail-fast guard examples.
  • Chapter 9 — codeunit business logic, table trigger fix, page action wiring, duplicate-generation guard.
  • Chapter 11 — extension packaging (app.json), permission set, event subscriber.
  • Chapter 13 — performance-focused loop/filter/load-field implementation and rationale notes.
  • Chapter 14 — test codeunit and regression tests.
  • Chapter 15 — data-classification audit output, role-scoped permission split.
  • Chapter 16 — Petty Cash Log case-study implementation (table/page/codeunit/permissions/tests) and design notes.

Each chapter page answers that chapter’s tasks in order. For chapters 9, 11, and 13–16, it also names the file that answers each code task and ends with a Code section showing those files in full.

Chapter 4 Worked Solutions

Task 1 — Type review (starter set)

  • Loan No. → Code[20] (structured identifier, compact and searchable).
  • Installment No. → Integer (ordered numeric sequence).
  • Principal Amount → Decimal (money-safe precision).
  • Paid → Boolean (binary business state).
  • Due Date → Date (date-only business deadline).
  • Payment Date → Date (date-only posting information).

Task 2 — Boundary practice

  • Code[20]
    • Valid: LN-2026-0001
    • Invalid: LOAN-2026-0001-EXTRA (more than 20 chars)
  • Text[100]
    • Valid: First disbursement linked to contract amendment.
    • Invalid: a paragraph beyond 100 chars
  • Decimal
    • Valid: 15250.75
    • Invalid: 15,250.75 USD (not a pure decimal value)

Task 3 — Variable scope cleanup

Expected answer pattern:

  • Keep only shared state as globals.
  • Move temporary calculation variables (InterestAmount, NetAmount) to local procedure var blocks.
  • Keep procedure parameters strongly typed instead of relying on globals.

Task 4 — Consistent identifier typing

Expected answer pattern:

  • Use Code[20] for Loan No. in table fields, page bindings, report columns, and codeunit procedure parameters.
  • Do not mix Text for the same identifier across objects.

Task 5 — Validation guard

Example guard:

if "Principal Amount" <= 0 then
    Error('Principal Amount must be greater than zero.');
if "Interest Amount" < 0 then
    Error('Interest Amount cannot be negative.');

Task 6 — Try it yourself

Sample answer:

  • Field: External Reference
  • Recommended type: Code[35] if it is a compact external identifier from another system.
  • Use Text[100] only if free-form human notes are expected.

Chapter 5 Worked Solutions

Task 1 — Operator warmup

  • Arithmetic: NetAmount := PrincipalAmount + InterestAmount;
  • Comparison: if InstallmentAmount > 0 then
  • Logical: if (InstallmentAmount > 0) and (DueDate <> 0D) then

Task 2 — Formula extraction

GrossAmount := Quantity * UnitPrice;
DiscountAmount := GrossAmount * DiscountPct;
NetAmount := GrossAmount - DiscountAmount;

This is easier to test and review than one dense inline expression.

Task 3 — Precedence check

  • Without parentheses: A + B * C evaluates multiplication first.
  • With parentheses: (A + B) * C changes the business result.

Rule: always use explicit parentheses for money formulas.

Task 4 — Condition hardening

IsDue := DueDate <= Today;
HasAmount := Amount > 0;
HasValidStatus := not Paid;
if (IsDue and HasAmount) and HasValidStatus then
    Message('Ready for processing.');

Task 5 — Validation expression

if (Amount <= 0) or (DueDate = 0D) then
    Error('Installment must have a positive amount and a due date.');

Task 6 — Ready-to-post expression

Sample expression:

ReadyToPost := (Amount > 0) and (DueDate <> 0D) and (not Paid);

Why each condition exists:

  • Amount > 0: blocks non-postable financial rows.
  • DueDate <> 0D: blocks incomplete scheduling data.
  • not Paid: prevents duplicate posting.

Chapter 6 Worked Solutions

Task 1 — Branching choice

Use case when you have discrete statuses:

case InstallmentStatus of
    InstallmentStatus::Open:
        HandleOpen();
    InstallmentStatus::Due:
        HandleDue();
    InstallmentStatus::Paid:
        HandlePaid();
end;

Task 2 — Decision fallback

case InstallmentStatus of
    InstallmentStatus::Open:
        HandleOpen();
    InstallmentStatus::Due:
        HandleDue();
    InstallmentStatus::Paid:
        HandlePaid();
else
    Error('Unsupported installment status: %1', Format(InstallmentStatus));
end;

Task 3 — Loop correction

if LoanRepaymentSchedule.FindSet() then
    repeat
        ProcessInstallment(LoanRepaymentSchedule);
    until LoanRepaymentSchedule.Next() = 0;

Task 4 — Loop intent check

Use for for known counts:

for InstallmentNo := 1 to NumberOfInstallments do
    CreateInstallment(InstallmentNo);

Task 5 — Guard clause

if Amount <= 0 then
    Error('Amount must be greater than zero before posting.');

Task 6 — Reduce nesting depth

Expected answer pattern:

  • Extract nested checks to helpers such as ValidateInstallment(Rec); or CanPostInstallment(Rec).
  • Keep top-level procedures focused on one control-flow path.
  • Return early on invalid data before main processing logic.

Chapter 9 Task Traceability

  • Task 1 → LoanRepaymentSchedule.Table.al (OnModify uses xRec.Paid).
  • Task 2 → LoanRepaymentMgt.Codeunit.al (codeunit 50100 / "Loan Repayment Mgt.").
  • Task 3 → LoanRepaymentMgt.Codeunit.al (MarkInstallmentPaid).
  • Task 4 → LoanRepaymentMgt.Codeunit.al (GenerateSchedule).
  • Task 5 → LoanRepaymentScheduleList.Page.al (MarkPaid action calls MarkInstallmentPaid).
  • Task 6 → LoanRepaymentMgt.Codeunit.al (existing-schedule guard by Loan No.).

Code

LoanRepaymentSchedule.Table.al

table 50100 "Loan Repayment Schedule"
{
    DataClassification = CustomerContent;

    fields
    {
        field(1; "Loan No."; Code[20])
        {
            DataClassification = CustomerContent;
        }
        field(2; "Installment No."; Integer)
        {
            DataClassification = CustomerContent;
        }
        field(3; "Vendor No."; Code[20])
        {
            DataClassification = CustomerContent;
            TableRelation = Vendor."No.";
        }
        field(4; "Due Date"; Date)
        {
            DataClassification = CustomerContent;
        }
        field(5; "Payment Date"; Date)
        {
            DataClassification = CustomerContent;
        }
        field(6; "Principal Amount"; Decimal)
        {
            DataClassification = CustomerContent;
        }
        field(7; "Interest Amount"; Decimal)
        {
            DataClassification = CustomerContent;
        }
        field(8; "Total Amount"; Decimal)
        {
            DataClassification = CustomerContent;
        }
        field(9; Paid; Boolean)
        {
            DataClassification = CustomerContent;
        }
    }

    keys
    {
        key(PK; "Loan No.", "Installment No.")
        {
            Clustered = true;
        }
    }

    trigger OnInsert()
    begin
        "Total Amount" := "Principal Amount" + "Interest Amount";
    end;

    trigger OnModify()
    begin
        if xRec.Paid then
            Error('A paid installment cannot be changed.');
        "Total Amount" := "Principal Amount" + "Interest Amount";
    end;
}

LoanRepaymentMgt.Codeunit.al

codeunit 50100 "Loan Repayment Mgt."
{
    procedure MarkInstallmentPaid(var LoanRepaymentSchedule: Record "Loan Repayment Schedule")
    begin
        LoanRepaymentSchedule."Payment Date" := Today;
        LoanRepaymentSchedule.Paid := true;
        LoanRepaymentSchedule.Modify(true);
    end;

    procedure GenerateSchedule(LoanNo: Code[20]; NumberOfInstallments: Integer; PrincipalPerInstallment: Decimal; InterestPerInstallment: Decimal; FirstDueDate: Date; VendorNo: Code[20])
    var
        LoanRepaymentSchedule: Record "Loan Repayment Schedule";
        i: Integer;
    begin
        LoanRepaymentSchedule.SetRange("Loan No.", LoanNo);
        if LoanRepaymentSchedule.FindFirst() then
            Error('A schedule already exists for loan %1.', LoanNo);

        for i := 1 to NumberOfInstallments do begin
            LoanRepaymentSchedule.Init();
            LoanRepaymentSchedule."Loan No." := LoanNo;
            LoanRepaymentSchedule."Installment No." := i;
            LoanRepaymentSchedule."Vendor No." := VendorNo;
            LoanRepaymentSchedule."Due Date" := CalcDate(StrSubstNo('<+%1M>', i - 1), FirstDueDate);
            LoanRepaymentSchedule."Principal Amount" := PrincipalPerInstallment;
            LoanRepaymentSchedule."Interest Amount" := InterestPerInstallment;
            LoanRepaymentSchedule.Insert(true);
        end;
    end;
}

LoanRepaymentScheduleList.Page.al

page 50100 "Loan Repayment Schedule List"
{
    PageType = List;
    SourceTable = "Loan Repayment Schedule";
    ApplicationArea = All;
    UsageCategory = Lists;
    Caption = 'Loan Repayment Schedule';

    layout
    {
        area(Content)
        {
            repeater(General)
            {
                field("Loan No."; Rec."Loan No.")
                {
                    ApplicationArea = All;
                }
                field("Installment No."; Rec."Installment No.")
                {
                    ApplicationArea = All;
                }
                field("Vendor No."; Rec."Vendor No.")
                {
                    ApplicationArea = All;
                }
                field("Due Date"; Rec."Due Date")
                {
                    ApplicationArea = All;
                }
                field("Payment Date"; Rec."Payment Date")
                {
                    ApplicationArea = All;
                }
                field("Principal Amount"; Rec."Principal Amount")
                {
                    ApplicationArea = All;
                }
                field("Interest Amount"; Rec."Interest Amount")
                {
                    ApplicationArea = All;
                }
                field("Total Amount"; Rec."Total Amount")
                {
                    ApplicationArea = All;
                }
                field(Paid; Rec.Paid)
                {
                    ApplicationArea = All;
                }
            }
        }
    }

    actions
    {
        area(Processing)
        {
            action(MarkPaid)
            {
                ApplicationArea = All;
                Caption = 'Mark Paid';
                Image = Approve;

                trigger OnAction()
                var
                    LoanRepaymentMgt: Codeunit "Loan Repayment Mgt.";
                begin
                    LoanRepaymentMgt.MarkInstallmentPaid(Rec);
                    CurrPage.Update(false);
                end;
            }
        }
    }
}

Chapter 11 Task Traceability

  • Task 1 → app.json (idRanges set to 50100–50149).
  • Task 2 → LoanRepayment.PermissionSet.al (permissionset 50100 "Loan Repayment").
  • Task 3 → LoanRepaymentEventSubscribers.Codeunit.al (Vendor OnAfterInsertEvent subscriber).
  • Task 4 → addressed in Chapter 15 split permission sets (Chapter 15 solutions).

Code

app.json

{
  "id": "5fe684dd-ff76-4e98-aad2-2b9294550110",
  "name": "Loan Repayment Management",
  "publisher": "Programming for All",
  "version": "1.0.0.0",
  "brief": "Track loan installments and repayment status.",
  "idRanges": [
    {
      "from": 50100,
      "to": 50149
    }
  ],
  "dependencies": [],
  "runtime": "12.0"
}

LoanRepayment.PermissionSet.al

permissionset 50100 "Loan Repayment"
{
    Assignable = true;
    Caption = 'Loan Repayment Management';

    Permissions =
        table "Loan Repayment Schedule" = X,
        tabledata "Loan Repayment Schedule" = RIMD,
        page "Loan Repayment Schedule List" = X,
        codeunit "Loan Repayment Mgt." = X;
}

LoanRepaymentEventSubscribers.Codeunit.al

codeunit 50101 "Loan Repayment Event Subscribers"
{
    [EventSubscriber(ObjectType::Table, Database::Vendor, 'OnAfterInsertEvent', '', false, false)]
    local procedure OnAfterInsertVendor(var Rec: Record Vendor)
    begin
        Message('New vendor %1 created — remember to review repayment schedules.', Rec."No.");
    end;
}

Chapter 13 Task Traceability

  • Task 1 → LoanRepaymentPerformance.Codeunit.al (CountUnpaidInstallments uses SetRange(Paid, false) before FindSet).
  • Task 2 → LoanRepaymentPerformance.Codeunit.al (SetLoadFields(Paid) added).
  • Task 3 → LoanRepaymentPerformance.Codeunit.al (ExplainBorrowerLookupPlacement documents lookup placement outside loop).
  • Task 4 → LoanRepaymentMgt.Codeunit.al in Chapter 9 keeps trigger-driven insert behavior; optimize writes only after measuring realistic volume, lock behavior, and trigger requirements.

Code

LoanRepaymentPerformance.Codeunit.al

codeunit 50105 "Loan Repayment Performance"
{
    procedure CountUnpaidInstallments(): Integer
    var
        LoanRepaymentSchedule: Record "Loan Repayment Schedule";
        UnpaidCount: Integer;
    begin
        LoanRepaymentSchedule.SetRange(Paid, false);
        LoanRepaymentSchedule.SetLoadFields(Paid);

        if LoanRepaymentSchedule.FindSet() then
            repeat
                UnpaidCount += 1;
            until LoanRepaymentSchedule.Next() = 0;

        exit(UnpaidCount);
    end;

    procedure ExplainBorrowerLookupPlacement(): Text
    begin
        exit('If installments are processed for one loan at a time, load borrower data once before the loop and reuse it inside the loop.');
    end;
}

Chapter 14 Task Traceability

  • Task 1 → LoanRepaymentMgtTests.Codeunit.al (Subtype = Test).
  • Task 2 → LoanRepaymentMgtTests.Codeunit.al (MarkInstallmentPaidSetsPaymentDate).
  • Task 3 → LoanRepaymentMgtTests.Codeunit.al (MarkingPaidDoesNotTriggerBlockedEditError).
  • Task 4 → covered by the same regression test design: it fails if xRec.Paid is reverted to Paid.
  • Task 5 → LoanRepaymentMgtTests.Codeunit.al (GenerateScheduleCreatesThreeRows).

Code

LoanRepaymentMgtTests.Codeunit.al

codeunit 50102 "Loan Repayment Mgt. Tests"
{
    Subtype = Test;

    [Test]
    procedure MarkInstallmentPaidSetsPaymentDate()
    var
        LoanRepaymentSchedule: Record "Loan Repayment Schedule";
        LoanRepaymentMgt: Codeunit "Loan Repayment Mgt.";
        Assert: Codeunit Assert;
    begin
        LoanRepaymentSchedule.Init();
        LoanRepaymentSchedule."Loan No." := 'LOAN0001';
        LoanRepaymentSchedule."Installment No." := 1;
        LoanRepaymentSchedule."Vendor No." := '10000';
        LoanRepaymentSchedule."Principal Amount" := 1000;
        LoanRepaymentSchedule."Interest Amount" := 50;
        LoanRepaymentSchedule.Insert(true);

        LoanRepaymentMgt.MarkInstallmentPaid(LoanRepaymentSchedule);

        Assert.IsTrue(LoanRepaymentSchedule.Paid, 'Installment should be marked paid.');
        Assert.AreEqual(Today, LoanRepaymentSchedule."Payment Date", 'Payment date should be today.');
    end;

    [Test]
    procedure MarkingPaidDoesNotTriggerBlockedEditError()
    var
        LoanRepaymentSchedule: Record "Loan Repayment Schedule";
        LoanRepaymentMgt: Codeunit "Loan Repayment Mgt.";
        Assert: Codeunit Assert;
    begin
        LoanRepaymentSchedule.Init();
        LoanRepaymentSchedule."Loan No." := 'LOAN0002';
        LoanRepaymentSchedule."Installment No." := 1;
        LoanRepaymentSchedule."Vendor No." := '10000';
        LoanRepaymentSchedule.Insert(true);

        LoanRepaymentMgt.MarkInstallmentPaid(LoanRepaymentSchedule);

        Assert.IsTrue(LoanRepaymentSchedule.Paid, 'Marking paid should succeed without error.');
    end;

    [Test]
    procedure GenerateScheduleCreatesThreeRows()
    var
        LoanRepaymentSchedule: Record "Loan Repayment Schedule";
        LoanRepaymentMgt: Codeunit "Loan Repayment Mgt.";
        Assert: Codeunit Assert;
    begin
        LoanRepaymentSchedule.SetRange("Loan No.", 'LOAN0003');
        LoanRepaymentSchedule.DeleteAll(true);

        LoanRepaymentMgt.GenerateSchedule('LOAN0003', 3, 1000, 100, Today, '10000');

        LoanRepaymentSchedule.SetRange("Loan No.", 'LOAN0003');
        Assert.AreEqual(3, LoanRepaymentSchedule.Count(), 'Exactly 3 installments should be generated.');
    end;
}

Chapter 15 Task Traceability

  • Task 1 → LoanRepaymentSchedule.Table.al (all Loan Repayment Schedule fields classified as CustomerContent per chapter guidance).
  • Task 2 → LoanRepaymentView.PermissionSet.al and LoanRepaymentFull.PermissionSet.al (split “View” and “Full” model).
  • Task 3 → Role assignment rationale:
    • View: assigned to finance auditors because they must inspect repayment status without changing records.
    • Full: assigned to loan operations users because they create schedules and mark installments paid.
  • Task 4 → "Borrower Name" should be EndUserIdentifiableInformation; keep it off broad-access pages and expose only via role-restricted page extension.

Code

LoanRepaymentSchedule.Table.al

table 50100 "Loan Repayment Schedule"
{
    DataClassification = CustomerContent;

    fields
    {
        field(1; "Loan No."; Code[20])
        {
            DataClassification = CustomerContent;
        }
        field(2; "Installment No."; Integer)
        {
            DataClassification = CustomerContent;
        }
        field(3; "Vendor No."; Code[20])
        {
            DataClassification = CustomerContent;
            TableRelation = Vendor."No.";
        }
        field(4; "Due Date"; Date)
        {
            DataClassification = CustomerContent;
        }
        field(5; "Payment Date"; Date)
        {
            DataClassification = CustomerContent;
        }
        field(6; "Principal Amount"; Decimal)
        {
            DataClassification = CustomerContent;
        }
        field(7; "Interest Amount"; Decimal)
        {
            DataClassification = CustomerContent;
        }
        field(8; "Total Amount"; Decimal)
        {
            DataClassification = CustomerContent;
        }
        field(9; Paid; Boolean)
        {
            DataClassification = CustomerContent;
        }
    }

    keys
    {
        key(PK; "Loan No.", "Installment No.")
        {
            Clustered = true;
        }
    }

    trigger OnInsert()
    begin
        "Total Amount" := "Principal Amount" + "Interest Amount";
    end;

    trigger OnModify()
    begin
        if xRec.Paid then
            Error('A paid installment cannot be changed.');
        "Total Amount" := "Principal Amount" + "Interest Amount";
    end;
}

LoanRepaymentView.PermissionSet.al

permissionset 50103 "Loan Repayment - View"
{
    Assignable = true;
    Caption = 'Loan Repayment (View Only)';

    Permissions =
        table "Loan Repayment Schedule" = X,
        tabledata "Loan Repayment Schedule" = R,
        page "Loan Repayment Schedule List" = X;
}

LoanRepaymentFull.PermissionSet.al

permissionset 50104 "Loan Repayment - Full"
{
    Assignable = true;
    Caption = 'Loan Repayment (Full Access)';
    IncludedPermissionSets = "Loan Repayment - View";

    Permissions =
        tabledata "Loan Repayment Schedule" = IMD,
        codeunit "Loan Repayment Mgt." = X;
}

Chapter 16 Task Traceability

  • Task 1 → Extension flow (text diagram):
    1. User opens page (PettyCashLogList.Page.al).
    2. Table enforces data rules and edit restrictions (PettyCashLog.Table.al).
    3. Page action calls business logic (MarkReconciled in PettyCashMgt.Codeunit.al).
    4. Test protects behavior (PettyCashMgtTests.Codeunit.al).
    5. Permission sets control access (PettyCashView.PermissionSet.al, PettyCashFull.PermissionSet.al).
    6. Field classification supports compliance (PettyCashLog.Table.al data classifications).
  • Task 2 → Implemented in:
    • PettyCashLog.Table.al
    • PettyCashLogList.Page.al
    • PettyCashMgt.Codeunit.al
    • PettyCashView.PermissionSet.al
    • PettyCashFull.PermissionSet.al
    • PettyCashMgtTests.Codeunit.al
  • Task 3 → A composite key is unnecessary because entries are independent transactions and do not belong to a natural grouped sequence like installments within one loan.

Code

PettyCashLog.Table.al

table 50110 "Petty Cash Log"
{
    DataClassification = CustomerContent;

    fields
    {
        field(1; "Entry No."; Integer)
        {
            DataClassification = SystemMetadata;
            AutoIncrement = true;
        }
        field(2; "Received By"; Code[20])
        {
            DataClassification = EndUserIdentifiableInformation;
            TableRelation = Employee."No.";
        }
        field(3; Amount; Decimal)
        {
            DataClassification = CustomerContent;
            MinValue = 0.01;
        }
        field(4; Purpose; Text[100])
        {
            DataClassification = CustomerContent;
            NotBlank = true;
        }
        field(5; "Entry Date"; Date)
        {
            DataClassification = CustomerContent;
        }
        field(6; Reconciled; Boolean)
        {
            DataClassification = CustomerContent;
        }
    }

    keys
    {
        key(PK; "Entry No.")
        {
            Clustered = true;
        }
    }

    trigger OnModify()
    begin
        if xRec.Reconciled then
            Error('A reconciled petty cash entry cannot be changed.');
    end;
}

PettyCashLogList.Page.al

page 50110 "Petty Cash Log List"
{
    PageType = List;
    SourceTable = "Petty Cash Log";
    ApplicationArea = All;
    UsageCategory = Lists;
    Caption = 'Petty Cash Log';

    layout
    {
        area(Content)
        {
            repeater(General)
            {
                field("Entry No."; Rec."Entry No.")
                {
                    ApplicationArea = All;
                }
                field("Received By"; Rec."Received By")
                {
                    ApplicationArea = All;
                }
                field(Amount; Rec.Amount)
                {
                    ApplicationArea = All;
                }
                field(Purpose; Rec.Purpose)
                {
                    ApplicationArea = All;
                }
                field("Entry Date"; Rec."Entry Date")
                {
                    ApplicationArea = All;
                }
                field(Reconciled; Rec.Reconciled)
                {
                    ApplicationArea = All;
                }
            }
        }
    }

    actions
    {
        area(Processing)
        {
            action(MarkReconciled)
            {
                ApplicationArea = All;
                Caption = 'Mark Reconciled';
                Image = Approve;

                trigger OnAction()
                var
                    PettyCashMgt: Codeunit "Petty Cash Mgt.";
                begin
                    PettyCashMgt.MarkReconciled(Rec);
                    CurrPage.Update(false);
                end;
            }
        }
    }
}

PettyCashMgt.Codeunit.al

codeunit 50111 "Petty Cash Mgt."
{
    procedure MarkReconciled(var PettyCashLog: Record "Petty Cash Log")
    begin
        PettyCashLog.Reconciled := true;
        PettyCashLog.Modify(true);
    end;
}

PettyCashView.PermissionSet.al

permissionset 50112 "Petty Cash - View"
{
    Assignable = true;
    Caption = 'Petty Cash (View Only)';

    Permissions =
        table "Petty Cash Log" = X,
        tabledata "Petty Cash Log" = R,
        page "Petty Cash Log List" = X;
}

PettyCashFull.PermissionSet.al

permissionset 50113 "Petty Cash - Full"
{
    Assignable = true;
    Caption = 'Petty Cash (Full Access)';
    IncludedPermissionSets = "Petty Cash - View";

    Permissions =
        tabledata "Petty Cash Log" = IMD,
        codeunit "Petty Cash Mgt." = X;
}

PettyCashMgtTests.Codeunit.al

codeunit 50114 "Petty Cash Mgt. Tests"
{
    Subtype = Test;

    [Test]
    procedure MarkReconciledSetsFlag()
    var
        PettyCashLog: Record "Petty Cash Log";
        PettyCashMgt: Codeunit "Petty Cash Mgt.";
        Assert: Codeunit Assert;
    begin
        PettyCashLog.Init();
        PettyCashLog."Received By" := 'E0001';
        PettyCashLog.Amount := 20;
        PettyCashLog.Purpose := 'Office supplies';
        PettyCashLog."Entry Date" := Today;
        PettyCashLog.Insert(true);

        PettyCashMgt.MarkReconciled(PettyCashLog);

        Assert.IsTrue(PettyCashLog.Reconciled, 'Entry should be marked reconciled.');
    end;
}