TWItys Blog

All Around Microsoft Dynamics Business Central

BusinessCraft: The Service Tier Runs AL

In the first BusinessCraft post, I said the next step would be the hard part: the service tier.

A few days later, craft-server can run two complete AL apps. You can publish them, sign in, open the role center, click through the pages, enter data, run actions, and see FlowFields update.

At first, I thought the way it got there was not what I asked for. That turned into an interesting discussion with the agent.

The two test apps

I wrote two small apps myself. They are intentionally simple, but they cover the typical things a real extension does.

BusinessCraftBaseApp

  • Table My Customer with an OnValidate trigger on Name
  • Table My Customer Cue with a count FlowField
  • List page My Customers with a page variable, an action, OnOpenPage, and OnAfterGetRecord
  • CardPart My Customer Activities with a cue group and drill-down
  • RoleCenter Business Craft Demo RC with the activities part and an embedded action
  • A profile pointing to the role center
  • A codeunit that subscribes to GetDefaultRoleCenterID

Customer Level (extension that depends on the base app)

  • A table extension that adds a Gold Level field and three table procedures
  • A table extension on the cue table with a FlowField that uses a where clause
  • A page extension that adds a field and three actions after SayHello
  • A page extension that adds a Gold Customers tile to the activities part

Here is one of the extension procedures that now runs in BusinessCraft:

internal procedure SayGoodBye()
var
    MyCustomer: Record "My Customer";
    GoodByeLbl: Label 'Goodbye, %1!', Comment = '%1 - Customer Name';
begin
    MyCustomer.SetFilter(Name, 'T*');
    MyCustomer.SetRange("Gold Level", true);
    if not MyCustomer.FindSet() then
        exit;

    repeat
        Message(GoodByeLbl, MyCustomer.Name);
    until MyCustomer.Next() = 0;
end;

And here is the FlowField from the extension that powers the gold customer tile:

field(50200; "Gold Customers"; Integer)
{
    FieldClass = FlowField;
    CalcFormula = count("My Customer" where("Gold Level" = const(true)));
}

Both apps run end to end. There is also an integration test (run_base_app.rs) that publishes them, opens the role center, inserts customers, and checks that the gold customer tile counts only gold customers.

How you run it

This feels very close to working with BC. You publish an app, create a company and a user, and start the web server:

# publish the base app and the extension (.app with source, or a folder with app.json)
craft-server --database sqlite://data/craft.db --app BaseApp
craft-server --database sqlite://data/craft.db --app LevelApp

# create a company and a user
craft-server --database sqlite://data/craft.db --company "My Company" --user admin --password admin

# serve the published apps to the web client
craft-web --backend server --database sqlite://data/craft.db

The web client from the first post now has a fourth backend: --backend server. That means the same Vue client can talk to the mock tier, the original BC web server, the original BC NST, or BusinessCraft's own service tier.

A few details are worth calling out:

  • Published once, then cached. The app is parsed when it is published, stored in SQLite, and loaded again without parsing on the next start.
  • App upgrades. Publishing a newer version replaces the stored app and keeps the company data.
  • BC-style table naming. Physical table names include the app ID of the app that declares the table, similar to BC's Company$Table$AppId pattern.
  • System fields. Every table gets SystemId, SystemCreatedAt, SystemCreatedBy, SystemModifiedAt, and SystemModifiedBy. The By fields are stamped with the signed-in user's SystemId.
  • Per-company data. Users sign in through the User table, and application data is stored per company.

Did the LLM ignore my advice?

This is the part I find most interesting.

My instruction was clear: do not interpret AL. Translate it into something compiled, either Rust or an intermediate form, similar to how Business Central compiles AL into C# and then runs it on .NET.

When I looked at the code, the agent seemed to have done something else.

It built a tree-walking interpreter. AL is effectively treated as a scripting language:

  1. craft-al parses the AL source into a syntax tree of statements (AlStmt) and expressions (AlExpr).
  2. At publish time, the syntax tree is serialized to JSON and stored in SQLite with the rest of the metadata.
  3. At runtime, craft-mock's Runner walks that tree statement by statement.

You can see that very clearly in the code:

  • Values are text. Inside a procedure, values are passed around as strings. A boolean is "true" if the string is not empty and not false.
  • Equality is string comparison. It trims trailing spaces and compares case-insensitively.
  • repeat ... until has a hard limit. Loops stop after 10,000 iterations to prevent endless loops.
  • Unknown syntax fails explicitly. If the parser or runtime does not support something, it fails with the source position and a message such as "call to 'X' is not supported yet".

My first reaction was: this is not what I asked for, and it is not how BC works.

Enter AL Runner

So I confronted the agent and gave it Stefan Maron's post AL Runner: Run AL Unit Tests Without a BC Service Tier.

AL Runner takes the opposite approach. It calls the public API of the BC compiler (Compilation.Emit()) to transpile AL into C#. It then rewrites the BC runtime types with in-memory mocks, compiles the result with Roslyn against the BC service tier DLLs, and runs the tests. That way, thousands of AL tests run in seconds without a container.

That is exactly the compile-first approach I had in mind.

The agent's answer was more nuanced than I expected. It had not ignored my wish. It had made a deliberate trade-off:

  • AL will be fast enough in a Rust runtime. Most AL execution time is spent in database calls, not in evaluating if statements or assignments. A small interpreter in Rust is fast compared to the SQL round trips around it.
  • The decision is reversible. Parser (craft-al), metadata (craft-metadata), and execution (Runner) are separate. If the interpreter becomes a bottleneck, the execution layer can be replaced with a compiler backend later, while the parser and metadata model stay the same.
  • AL Runner's shortcut is not available here. AL Runner relies on Microsoft's compiler and service tier DLLs. That is perfectly fine for a test tool, but BusinessCraft's clean-room rules do not allow Microsoft code in the project. Any compiler would have to be written from scratch in Rust.

I have to admit that this is a good argument.

The interpreter got two apps running in a few days, and every unsupported feature fails immediately with a clear error and source position.

The open question is the type system. "Everything is text" will not hold up for Decimal, Date, Code[20], or Option/Enum. A proper interpreter needs typed values, regardless of whether a compiler comes later.

What works now

This is the current supported subset of AL in craft-server.

Objects

Object Supported
table Fields, keys, Caption, ToolTip, DrillDownPageId, field OnValidate
tableextension New fields and procedures
page Layout, actions, page variables, triggers
pageextension addafter for fields and actions
codeunit Event subscribers
profile RoleCenter, caption, description

Enums, interfaces, queries, reports, XMLports, and permission sets are not supported yet.

Page types

The compiler accepts RoleCenter, List, Card, Document, Worksheet, ListPart, and CardPart.

The two apps exercise RoleCenter, List, and CardPart.

Layout and actions

  • group, repeater, cuegroup, part
  • field bound to Rec."Field" or to a page variable
  • Cue drill-down through the field's DrillDownPageId, or through the source table's DrillDownPageId
  • action with RunObject = page ... or an OnAction trigger
  • Page properties: SourceTable, UsageCategory, Editable, InsertAllowed, ModifyAllowed, DeleteAllowed

FlowFields

  • CalcFormula = count(...) and sum(...)
  • where clauses with const(...), filter(...), and field(...)

Triggers and events

Trigger Where
OnOpenPage Page
OnAfterGetRecord Page
OnAfterGetCurrRecord Page
OnAction Page action, including actions from page extensions
OnValidate Table field. Error is shown as a dialog and the value is kept
GetDefaultRoleCenterID Event subscriber on System Action Triggers. This is currently the only event the service tier raises

Table procedures can be called from page actions, for example Rec.SayGoodBye(), including procedures defined in a table extension of another app.

Statements

  • begin ... end
  • if ... then ... else
  • repeat ... until
  • exit
  • Variable := ...
  • Rec."Field" := ...
  • Procedure calls and record method calls

Expressions

  • Integer, text, and boolean literals
  • Local variables, page variables, and Label variables
  • Rec."Field"
  • = and not
  • Enum::Value
  • Record methods used as expressions, such as FindSet() or Next() = 0

Built-in functions

  • Message
  • Error
  • StrSubstNo

These support %1, %2, and so on, including labels.

Record methods

This is the largest area. The runtime handles about 80 record methods:

Area Methods
Read Get, GetBySystemId, Find, FindFirst, FindLast, FindSet, Next, IsEmpty, Count, CountApprox
Write Init, Insert, Modify, ModifyAll, Delete, DeleteAll, Rename, Validate, TransferFields, Truncate
Filters SetRange, SetFilter, Reset, GetFilter, GetFilters, HasFilter, FilterGroup, CopyFilter, CopyFilters, SetRecFilter, GetRangeMin, GetRangeMax, SetView, GetView
Keys and sorting SetCurrentKey, CurrentKey, Ascending, SetAscending, GetAscending
FlowFields CalcFields, CalcSums, SetAutoCalcFields
Partial records SetLoadFields, AddLoadFields, LoadFields, AreFieldsLoaded, SetBaseLoadFields
Validation TestField, FieldError
Marks Mark, MarkedOnly, ClearMarks
Metadata TableName, TableCaption, FieldName, FieldCaption, FieldNo, FieldActive, FullyQualifiedName, RecordId, IsTemporary, IsDirty, Relation
Position GetPosition, SetPosition
Company ChangeCompany, CurrentCompany
Transactions and isolation LockTable, ReadIsolation, ReadConsistency, Consistent, RecordLevelLocking
Permissions and security ReadPermission, WritePermission, SetPermissionFilter, SecurityFiltering
Links AddLink, DeleteLink, DeleteLinks, HasLinks, CopyLinks
Other Copy

Temporary records (Record "X" temporary) are supported too.

What does not work yet

The list of missing features is still longer than the list of working ones:

  • Comparison operators other than = (<>, <, >, <=, >=)
  • and, or, and arithmetic (+, -, *, /)
  • while, for, and case
  • Confirm, StrMenu, and suspending a call while waiting for the user
  • Codeunit Run, events other than GetDefaultRoleCenterID, permissions, and number series
  • A real type system. Everything is still text

So no, BusinessCraft cannot run the Base App yet. Not even close.

But it can run real AL apps with tables, extensions, FlowFields, triggers, and actions.

A detour: Grok

As mentioned in the first post, I am using Cursor for BusinessCraft. I wanted to give it a proper try. Most people on our team use Claude or OpenAI privately, so I thought I would test the last big harness as well.

Unfortunately, the web client used up my budget for third-party models. Until it resets next week, the only model left for me is Grok 4.7.

My plan was to just wait. According to Reddit, Grok is a model straight from hell. I expected nothing and tried it anyway.

The result is surprisingly good.

Maybe that is because of the structure Opus 5.5 had already put in place. The crates, the metadata model, and the conventions were there. Grok mostly had to implement technical specifications within that frame, not design the system from scratch.

Either way, it reminded me of something: you have to test a lot of things yourself, no matter what benchmarks or Reddit say.

Numbers

  • 52 commits since the start
  • About 31,500 lines of Rust, up from about 17,000 in the first post
  • Four backends for one web client: mock, BC web server, BC NST, and BusinessCraft's own service tier

Next

The plan is now:

  1. Keep the interpreter and close the gaps: operators, case, while, for, Confirm with suspension, codeunits, and enums.
  2. Introduce real typed values instead of strings.
  3. Measure. If AL in Rust is not fast enough, rebuild the execution layer as a compiler, the way BC does it with .NET.

The agent bet on "fast enough". I am curious whether it is right.

I'll keep you updated. :)


BusinessCraft is not affiliated with or endorsed by Microsoft. "Microsoft", "Dynamics 365" and "Business Central" are trademarks of Microsoft Corporation.