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
- Chapter 1: Understanding Application Language (AL)
- Chapter 2: Setting Up Your AL Development Environment
- Chapter 3: First Steps with AL
Part II: Core Concepts of AL
- Chapter 4: Data Types and Variables in AL
- Chapter 5: Operators and Expressions
- Chapter 6: Control Structures
Part III: AL for Business Central Development
- Chapter 7: Creating and Managing Tables
- Chapter 8: Designing Pages and User Interfaces
- Chapter 9: Developing Business Logic with Codeunits
- Chapter 10: Reports and Data Analysis
- Chapter 11: Extensions and Customizations
- Chapter 12: Integration with Other Systems
Part IV: Best Practices and Optimization
- Chapter 13: Performance Optimization
- Chapter 14: Testing and Debugging
- Chapter 15: Security and Compliance
Part V: Real-World Applications
- Chapter 16: ERP Case Studies and Examples
- Chapter 17: Advanced AL Features
- Chapter 18: Contributing to the AL Community
Part VI: Appendices
- Appendix A: AL Syntax Reference
- Appendix B: Troubleshooting and FAQs
- Appendix C: Resources and Further Reading
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:
- Create table structure and constraints (Chapter 7)
- Add a list page and actions (Chapter 8)
- Move logic into codeunits (Chapter 9)
- Package and secure the extension (Chapters 11 and 15)
- 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
- Architecture mapping. Write one sentence each for what belongs in a table, page, and codeunit.
- Upgrade reasoning. Explain why extension packaging is safer than editing base objects directly.
- 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:
- Visual Studio Code
- The AL Language extension (from Microsoft)
- A Business Central sandbox environment
- 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.jsonhas 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
- Project bootstrap. Create a new AL project and verify
app.jsonexists. - Connection validation. Configure
launch.jsonand complete one successful publish. - Debug readiness. Set a breakpoint in any procedure and verify debugger attach works.
- 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:
- Open your page in Business Central.
- Create sample data.
- Trigger your page action.
- 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:
- Reproduce one issue.
- Place breakpoints at object boundaries (page action, then codeunit, then table trigger).
- Inspect
Recvalues before and after procedure calls. - 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
- Create a starter object set. Add one table, one page, and one codeunit.
- Wire an action. Add a page action that calls a codeunit procedure.
- Validate update flow. Confirm the called procedure changes data as expected.
- 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:
Recordfor table rowsListandDictionaryfor 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
TestFieldor an explicitError. - 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
- 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. - Boundary practice. For
Code[20],Text[100], andDecimal, write one valid and one invalid sample value for each type. - Variable scope cleanup. Move at least two global variables into a local procedure scope where they are only used once.
- Consistent identifier typing. Check every place where
Loan No.is used and ensure the type is consistentlyCode[20]. - Validation guard. Add explicit validation so
Principal AmountandInterest Amountcannot be zero or negative before calculation. - Try it yourself. Add one extra field of your choice (for example,
External Reference) and decide whether it should beCodeorText, 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
IsOverduevariable.
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
- Operator warmup. Write one AL example each for arithmetic, comparison, and logical operators using loan-installment values.
- Formula extraction. Replace one long inline expression with named local variables (
GrossAmount,DiscountAmount,NetAmount). - Precedence check. Evaluate the same formula once without parentheses and once with parentheses; explain the difference.
- Condition hardening. Rewrite one multi-condition
ifwith explicit parentheses and positive naming (IsDue,HasAmount, etc.). - Validation expression. Add one expression that rejects invalid installment input (
Amount <= 0, missing date, or invalid status combination). - 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, andrepeat..untilbased 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 iterationrepeat..until: record traversal afterFindSet
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
- Branching choice. Replace one overgrown
ifchain with acasewhere appropriate. - Decision fallback. Add an
elsebranch (orcase else) that raises anErrorfor unsupported state values. - Loop correction. Refactor one loop to use
FindSet+repeat..untilfor record traversal. - Loop intent check. Convert one loop to
forwhen the iteration count is known ahead of time. - Guard clause. Add one fail-fast
Errorthat protects invalid input before any data changes. - 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 = truewhere empty values are invalidTableRelationfor foreign-key style lookups, like"Vendor No."above, which must match a record in the standardVendortableMinValuewhere 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 Amountfrom 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/xRecsemantics - Laid a table foundation for the page and codeunit work
Tasks
- Create the table. Implement
Loan Repayment Schedulewith composite key and core fields. - Add constraints. Apply
TableRelation,NotBlank, and value guards where needed. - Harden triggers. Ensure
OnModifychecksxRec.Paidbefore allowing edits. - 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
- Create the list page. Build a page for
Loan Repayment Schedule. - Add a business action. Add
Mark Paidwired to a codeunit call. - Review field set. Keep only fields required for day-to-day installment operations.
- 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
RecvsxRecdistinction 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:
varbefore a parameter passes it by reference, so changes the procedure makes toLoanRepaymentScheduleare visible to the caller. Leave offvarfor a parameter you only want to read, like the amounts going intoCalculateTotal.
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:
Recis “the record as it will be saved” andxRecis “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 needxRec. If your table from Chapter 7 checksPaidinstead, 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 plainInsert(), tells AL to run the table’sOnInserttrigger. 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
xRecto see what changed. Never assumeRecalone tells you the previous state. - Return simple values with
exit, and change records throughvarparameters. 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
xRecand notRec - 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.
- Check Chapter 7. Go back to your
Loan Repayment Scheduletable’sOnModifytrigger and make sure it testsxRec.Paid, notPaid. OnceMarkInstallmentPaidexists, confirm you can mark an installment paid without the trigger rejecting it. - Create the codeunit. Create
Codeunits/LoanRepaymentMgt.Codeunit.alwith ID50100and nameLoan Repayment Mgt.. - Add
MarkInstallmentPaid. Add the public procedure from 9.2 that sets"Payment Date"andPaid, then callsModify(true). - Add
GenerateSchedule. Add the procedure from 9.4 that insertsNumberOfInstallmentsrows for a given loan, spaced one month apart. - Wire it to a page action. On your
Loan Repayment Schedulelist page (from Chapter 8), add an action that callsMarkInstallmentPaidon the selected record. - Try it yourself.
GenerateScheduledoesn’t check whether a schedule already exists forLoanNobefore 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
- Create a report. Build a report over
Loan Repayment Scheduleshowing due date and total amount. - Add filters. Add at least one request filter (for example,
Loan No.orPaid). - Validate totals. Confirm reported
Total Amountmatches table-calculated values. - 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, withR,I,M, andDfor read, insert, modify, and delete.Xon 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, granttabledata ... = Rinstead ofRIMD. 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
Messagehere 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:
Ron table data where read-only access is enough, andRIMDonly 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.jsonand 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
- Write your
app.json. Create one for the Loan Repayment extension with anidRangesblock covering50100–50149, and confirm every object you built in Chapters 7–9 falls inside it. - Create the permission set. Add
permissionset 50100 "Loan Repayment"grantingRIMDon your table’s data andXon your table, page, and codeunit. - Add an event subscriber. Subscribe to
Vendor’sOnAfterInsertEvent(or another standard table’s insert event) and log or message something relevant to your extension. - Try it yourself. Your permission set currently grants full
RIMDaccess 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
HttpClientand 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
- Service endpoint. Create a codeunit procedure that returns installment status, and publish the codeunit as a web service.
- HTTP robustness. Implement one outbound API call that checks
IsSuccessStatusCodeand handles failures. - Validation gate. Add payload validation that rejects missing or invalid required values.
- 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
SetLoadFieldsto 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/SetFilteralways go beforeFindSet. 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
SetLoadFieldsand 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/SetFilterbeforeFindSet, and never check a condition and skip rows inside the loop. - Use
SetLoadFieldson 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/SetFilterbefore looping, not after - Used
SetLoadFieldsto avoid loading unused columns - Learned to hoist unchanging lookups out of a loop
Tasks
- Filter
GenerateSchedule’s caller. Write a procedure that loops over all unpaid installments across every loan and prints a count. UseSetRange(Paid, false)beforeFindSet, not anifinside the loop. - Add
SetLoadFields. To the procedure from task 1, addSetLoadFieldslisting only the fields you actually read. - Find the hoist. Imagine a procedure that, for every installment,
looks up the loan’s borrower name from a
Loanheader 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. - Try it yourself.
GenerateSchedulefrom Chapter 9 inserts records one at a time withInsert(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
Assertcodeunit 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:
Assertis 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’sapp.json.Assert.AreEqualtakes 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 theRec/xRecbug 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
MarkInstallmentPaidSetsPaymentDateshouldn’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.IsTrueandAssert.AreEqual - Wrote a regression test for the
xReccheck from Chapter 9 - Learned how to set a breakpoint and step through code
Tasks
- Create the test codeunit. Create
Codeunits/LoanRepaymentMgtTests.Codeunit.alwithSubtype = Test. - Write the paid-date test. Add
MarkInstallmentPaidSetsPaymentDatefrom 14.2. - Write the regression test. Add
MarkingPaidDoesNotTriggerBlockedEditErrorfrom 14.3, and confirm it passes against your current table. - Break it on purpose. Temporarily change
xRec.PaidtoPaidin your table’sOnModifytrigger, rerun the test, and confirm it now fails. Then putxRec.Paidback. - Try it yourself. Write a third test for
GenerateSchedule: givenNumberOfInstallments = 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
DataClassificationcommunicates 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:
CustomerContentfor business data that belongs to the customerEndUserIdentifiableInformationfor anything that identifies a specific personSystemMetadatafor 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:
| Field | Classification | Why |
|---|---|---|
"Loan No." | CustomerContent | Business data, not personal |
"Installment No." | CustomerContent | Business data |
"Due Date" / "Payment Date" | CustomerContent | Business data |
"Principal Amount" / "Interest Amount" / "Total Amount" | CustomerContent | Financial business data |
Paid | CustomerContent | Business 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, notCustomerContent.
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:
IncludedPermissionSetslets 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
ToBeClassifiedin 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
IncludedPermissionSetsinstead 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
DataClassificationas 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
- Audit your table. Go through every field on
Loan Repayment Scheduleand confirm itsDataClassificationmatches the table in 15.2. Fix any that don’t. - Split the permission set. Replace the single permission set from Chapter 11 with the “View” and “Full” pair from 15.3.
- Assign roles. Decide which of your test users would get “View” and which would get “Full,” and write one sentence justifying each.
- Try it yourself. Suppose a future chapter adds a
"Borrower Name"field directly ontoLoan Repayment Schedule(skipping the lookup to aLoantable). WhatDataClassificationwould 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:
- A purchasing or finance user opens the page (Chapter 8) built on
Loan Repayment Schedule. - 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. - 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. - That codeunit’s logic is protected by a test (Chapter 14) that
would fail loudly if a future change reintroduced the
Rec/xRecbug from Chapter 9. - 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.
- 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, orTableRelation? - What trigger enforces “reconciled entries can’t be edited,” and does
it need the
Rec/xReclesson 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
.alfile.
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
- 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.
- 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.
- 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
- Publish an event. Add an integration event to your loan-management flow.
- Subscribe responsibly. Create one subscriber codeunit that reacts without duplicating core logic.
- Introduce an interface. Define one interface and one implementation for a replaceable service.
- 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:
- Problem statement: what was wrong and why it matters.
- Change summary: what was modified and where.
- Validation evidence: tests, repro output, or before/after behavior.
- 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
- Contribution assessment. Review one AL PR and score it against the four quality standards in 18.2.
- Improvement proposal. Draft a high-value issue that includes reproducible steps and expected behavior.
- Documentation task. Update one technical section so another contributor can execute it without extra context.
- 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/endfor multi-line blocks - Calling
FindSetbeforeSetRange - Checking
Recwhen prior state requiresxRec - Mixing field names with inconsistent casing or quoting
A.4 Quick lookup table
| Need | Recommended AL construct |
|---|---|
| Reusable business rule | Codeunit procedure |
| Data integrity at source | Table trigger + field properties |
| User-triggered workflow | Page action calling codeunit |
| Extension reaction point | Publisher/subscriber events |
| Regression safety | Test 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
- Pattern matching. Pick two chapter snippets and map each to a pattern in A.2.
- Pitfall review. Check one of your objects for at least two A.3 pitfalls.
- 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)
- Environment check: the AL extension for VS Code is installed, symbols are downloaded, and the sandbox is reachable.
- Compile check: resolve the first error in the output before chasing the errors that follow from it.
- Repro check: reproduce the problem with the fewest steps and known sample data.
- Boundary check: inspect each transition from page action to codeunit to table trigger.
- Data check: verify key fields and your assumptions about prior state (
xRec). - Regression check: add or run tests for the fixed behavior (Chapter 14).
B.2 Common error catalog
| Error symptom | Likely cause | First fix step |
|---|---|---|
Table ... not found | Missing symbol reference or wrong object ID/range | Re-download symbols and verify object metadata |
The record already exists | Duplicate key on insert | Validate key design and duplicate guards |
You do not have permission ... | Missing permission set grants | Review Chapter 11/15 permission setup |
Runtime cannot be changed unexpectedly | Trigger checks current state (Rec) instead of prior state (xRec) | Correct trigger logic and add regression test |
| API call fails with non-success status | Remote endpoint/auth/contract mismatch | Log 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
- Checklist run. Apply B.1 to one recent issue you encountered.
- Error mapping. Add one new row to the error catalog from your own experience.
- 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
| Goal | Primary tool | Why it matters |
|---|---|---|
| Author AL objects | Visual Studio Code + AL extension | Core development workflow |
| Publish/debug extension | Business Central sandbox + debugger | Safe runtime validation |
| Validate behavior | AL test codeunits | Regression protection |
| Collaborate | GitHub issues/PRs | Shared review and improvement loop |
C.2 Curated learning path
Beginner track
- Chapters 1 to 3 (foundation and setup)
- Chapters 4 to 6 (language basics)
- Chapters 7 to 9 (table/page/codeunit architecture)
Intermediate track
- Chapter 10 (reporting)
- Chapter 11 (packaging and permissions)
- Chapter 12 (integration patterns)
- Chapters 13 to 15 (performance, testing, security)
Advanced/contributor track
- Chapter 16 (case-study synthesis)
- Chapter 17 (advanced extensibility patterns)
- Chapter 18 (quality contributions)
C.3 Recommended references
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
- Path selection. Choose a track and list your next three chapters.
- Resource mapping. Pair one external resource with each selected chapter.
- 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)
- Valid:
Text[100]- Valid:
First disbursement linked to contract amendment. - Invalid: a paragraph beyond 100 chars
- Valid:
Decimal- Valid:
15250.75 - Invalid:
15,250.75 USD(not a pure decimal value)
- Valid:
Task 3 — Variable scope cleanup
Expected answer pattern:
- Keep only shared state as globals.
- Move temporary calculation variables (
InterestAmount,NetAmount) to local procedurevarblocks. - Keep procedure parameters strongly typed instead of relying on globals.
Task 4 — Consistent identifier typing
Expected answer pattern:
- Use
Code[20]forLoan No.in table fields, page bindings, report columns, and codeunit procedure parameters. - Do not mix
Textfor 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 * Cevaluates multiplication first. - With parentheses:
(A + B) * Cchanges 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);orCanPostInstallment(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(OnModifyusesxRec.Paid). - Task 2 →
LoanRepaymentMgt.Codeunit.al(codeunit50100/"Loan Repayment Mgt."). - Task 3 →
LoanRepaymentMgt.Codeunit.al(MarkInstallmentPaid). - Task 4 →
LoanRepaymentMgt.Codeunit.al(GenerateSchedule). - Task 5 →
LoanRepaymentScheduleList.Page.al(MarkPaidaction callsMarkInstallmentPaid). - Task 6 →
LoanRepaymentMgt.Codeunit.al(existing-schedule guard byLoan 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(idRangesset to50100–50149). - Task 2 →
LoanRepayment.PermissionSet.al(permissionset 50100 "Loan Repayment"). - Task 3 →
LoanRepaymentEventSubscribers.Codeunit.al(VendorOnAfterInsertEventsubscriber). - 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(CountUnpaidInstallmentsusesSetRange(Paid, false)beforeFindSet). - Task 2 →
LoanRepaymentPerformance.Codeunit.al(SetLoadFields(Paid)added). - Task 3 →
LoanRepaymentPerformance.Codeunit.al(ExplainBorrowerLookupPlacementdocuments lookup placement outside loop). - Task 4 →
LoanRepaymentMgt.Codeunit.alin 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.Paidis reverted toPaid. - 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(allLoan Repayment Schedulefields classified asCustomerContentper chapter guidance). - Task 2 →
LoanRepaymentView.PermissionSet.alandLoanRepaymentFull.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 beEndUserIdentifiableInformation; 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):
- User opens page (
PettyCashLogList.Page.al). - Table enforces data rules and edit restrictions (
PettyCashLog.Table.al). - Page action calls business logic (
MarkReconciledinPettyCashMgt.Codeunit.al). - Test protects behavior (
PettyCashMgtTests.Codeunit.al). - Permission sets control access (
PettyCashView.PermissionSet.al,PettyCashFull.PermissionSet.al). - Field classification supports compliance (
PettyCashLog.Table.aldata classifications).
- User opens page (
- Task 2 → Implemented in:
PettyCashLog.Table.alPettyCashLogList.Page.alPettyCashMgt.Codeunit.alPettyCashView.PermissionSet.alPettyCashFull.PermissionSet.alPettyCashMgtTests.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;
}