DSL Engine
In AuraBoot, business pages are not React components written by hand. They are JSON documents that conform to a versioned schema and are rendered by a contract-driven engine at runtime. A list page, a form, a detail layout, a dashboard — all of them are data, not code.
This page explains why the platform is built that way, what the contracts are, and how the moving parts fit together: pages, blocks, fields, data sources, actions, and the validators that keep the whole thing honest.
Why DSL, not TSX, for business pages
The temptation to "just write a React component for this one screen" is real. It is also where most low-code platforms quietly turn into a regular codebase with a designer bolted on the side.
AuraBoot takes the opposite stance: every business page is described by data, and the renderer is the only thing allowed to interpret that data.
- Fail-fast contract. A page schema either matches the published contract or it does not load. There is no half-broken render that limps along with a missing button. Unknown block types throw. Deprecated aliases throw with the new name in the error message. Missing required fields surface as validation errors before the form ever submits.
- Upgrade-safe rendering. Because pages are versioned schemas, the platform can evolve the renderer (better empty states, accessibility fixes, performance tweaks, a new design language) and every existing page picks up the improvement on the next deploy. Hand-written TSX would have to be touched one screen at a time.
- AI-readable schema. Tools, automations, and AI assistants can read a page schema the same way they read a database row. A model that needs to add a column to a list, fill a field on a form, or wire a new action does not have to parse JSX, infer state, and guess intent. The schema is the intent.
- One mental model across teams. Plugin authors, integrators, and operators all work in the same JSON shape. A field is a field whether it lives in a CRM contact form or in a maintenance order detail page.
The escape hatch is real but narrow: a custom block can load a hand-written component when the requirement is genuinely outside the schema's vocabulary. That hatch exists so the contract does not have to absorb every edge case, but using it is a deliberate choice, not the default.
Concretely, the choice between DSL and TSX looks like this:
| Need | Right tool |
|---|---|
| List, form, detail, dashboard for a model | DSL page |
| Toolbar button that invokes a command | DSL action |
| Conditional field visibility based on another field | DSL visibleWhen |
| Cross-field validation on submit | Command precondition or binding rule |
| A new chart type the platform does not ship | Smart component contribution (still data-driven) |
| A genuinely bespoke widget (e.g., a 3D site map) | custom block + plugin component |
| A new business operation | Command (ExecutionConfig or BindingRule), never a page-level hack |
If you find yourself reaching for the custom block more than rarely, that is a signal — either the block vocabulary is missing a primitive that ought to be added, or the requirement is being modeled at the wrong layer.
The schema-first contract
Four object types form the contract surface for any page:
| Object | What it describes | Where it lives |
|---|---|---|
| Page | The top-level container: kind, layout, blocks, title | pages.json |
| Block | A renderable unit inside a page (table, form section, toolbar) | Nested inside a page |
| Field | A bound input or column with type, label, validation | Nested inside a block |
| Command | The named operation a button or action invokes | commands.json |
All four are JSON documents that conform to versioned schemas. They are stored in the platform, exported with the plugin, and validated on import.
A page does not "include" code. It references model fields, command codes, and i18n keys. The renderer resolves those references at runtime against the current metadata snapshot.
{
"id": "lead-list",
"kind": "list",
"title": { "en-US": "Leads", "zh-CN": "线索" },
"blocks": [
{ "blockType": "filters", "fields": [/* ... */] },
{ "blockType": "table", "dataSource": "lead", "columns": [/* ... */] },
{ "blockType": "toolbar", "buttons": [/* commands */] }
]
}Two gates protect that contract:
- A static audit, runnable locally before commit, catches the most common authoring mistakes: missing labels, unbound dictionaries, dangling command references, unregistered handlers.
- The platform import API is the real gate. A page schema is only accepted if every field resolves against the model, every command exists, every i18n key has a fallback, and every block is registered.
The static audit is fast and cheap, but it is not the contract. The platform validator is. A page that passes the static audit can still fail import — and the right response is to fix the page, not to weaken the validator.
Schemas are versioned. A page declares its schemaVersion, and the renderer dispatches to the matching parser. Migrations between versions are explicit programs rather than ad-hoc renames, which means a plugin authored against an older version continues to import and render until it is migrated forward. Backwards-compatible renames carry deprecation messages; backwards-incompatible changes require a migration step. The principle is simple: the contract is allowed to evolve, but the evolution is visible.
BlockRegistry and kindPolicy
A block is a named, renderable unit. The BlockRegistry is the single source of truth that maps a blockType string to a React component, a config schema, and metadata about where the block is allowed to appear.
The dispatcher resolves blocks in this order:
- Profile override (per-tenant or per-route customization, optional).
BlockRegistry.get(blockType)— the runtime registry.customblocks fall through to a dynamic component loader.
If the block type is unknown, the renderer throws. Renamed aliases (for example, a blockType that was retired during a schema migration) throw with a message that tells the author exactly what to rename it to. There is no silent fallback to an empty container.
unknown blockType "data-table"
-> throw: was renamed to "table" since 2026-03-30The complementary contract is kindPolicy: a declaration of which child blocks are valid inside which parent, for which page kind. It is the static counterpart of "what does this composition mean."
A kindPolicy entry answers questions like:
- Can a
form-sectionappear directly inside alistpage? (No.) - Can a
chartappear inside adashboard? (Yes.) - Can a
sub-tableappear inside adetailtab? (Yes.) - Can a
toolbarappear inside aform? (Yes — bottom buttons.)
kindPolicy is what makes the designer's palette accurate: when a user drags a chart over a list canvas, the policy says "not here" and the drop target refuses. When validated at import time, the same policy rejects pages whose nesting does not make sense for their kind.
New block types are registered by adding an entry to BlockRegistry (component plus optional data-shaping) and extending the relevant kindPolicy entries. Both must move together; a registered block that no policy admits is unreachable, and a policy that admits an unregistered block crashes the renderer.
A registry entry looks roughly like this:
BlockRegistry.register('kpi-card', {
component: KpiCardBlock,
// Optional: shape the raw API payload into what the component expects.
normalizeData: normalizeKpiData,
});And the kindPolicy entry it pairs with (each page kind declares its allowedBlockTypes set):
allowedBlockTypes: new Set<string>([
// ...block types already allowed on dashboard
'kpi-card',
]),That symmetry is enforced. The designer reads from both sides — the palette pulls from the registry, the drop validator pulls from the policy — and so does the platform validator at import time. The two halves of the contract cannot drift without one of the layers complaining.
Block taxonomy
Five page kinds and a small, deliberately stable set of block types cover the vast majority of business UI.
Page kinds.
list— paginated table with filters, toolbar, row actions, and saved views.form— create or edit a single record, optionally as a wizard.detail— read-oriented record view with header, tabs, sub-tables, and timeline.dashboard— grid of charts, KPIs, and bound widgets.composite— multi-region layout for screens that mix kinds.
Core block types.
formandform-section— single-record input.form-sectionis the grouped, multi-column form used in almost every detail edit screen.form-buttonsandtoolbar— action groups.form-buttonsanchors to a form;toolbaranchors to a list or detail header.table— paginated data grid with columns, row actions, and column-level configuration.filters— the query form above a list, with structured operator/value entries.tabs— a tab container that can host any other block, used for both list status tabs and detail page sections.sub-table— a child relationship rendered as an embedded grid on a parent's detail page.chart— a bridge to the chart component library; supports many chart types via a singlechartTypediscriminator.description— static or bound prose, used for help text and read-only summaries.field-historyandactivity-timeline— audit and activity blocks the platform can auto-inject on detail pages of suitable model categories.form-wizard— stepwise multi-page form for longer creation flows.monthly-grid— calendar-style grid for monthly breakdowns.custom— the escape hatch. Loads a component by name through the dynamic loader.
Composition is recursive. A tabs block can contain form-sections, each of which contains fields. A detail page can declare a header toolbar, a body tabs, and a sub-table inside one of those tabs. The renderer walks the tree; the registry resolves each node.
A typical detail page composes like this:
detail
├── toolbar (header buttons: edit, delete, custom commands)
├── form-section (read-only summary at the top)
└── tabs
├── form-section (editable fields, grouped)
├── sub-table (line items, related records)
├── field-history (auto-injected for auditable models)
└── activity-timelineThe block taxonomy is deliberately small. New blocks are added when a genuinely new primitive is needed — not for every visual variation. Visual variation is the renderer's job: the same table block looks different in a compact list view and in a dashboard widget because the renderer interprets layout hints, not because the schema has two block types.
ExecutionConfig vs BindingRule — the two action modes
Every button, row action, and form submit ultimately fires a command. How that command runs is described by one of two configurations.
ExecutionConfig is the declarative, JSON-only path. It lives on the command and describes the entire write operation as data: which model, which operation type, which field defaults, which state transition, which preconditions, which side effects. Around 80% of routine CRUD commands — create, update, delete, simple state transitions — never need more than this.
{
"code": "crm:qualify_lead",
"type": "state_transition",
"stateField": "crm_lead_status",
"fromStates": ["new", "contacted"],
"toState": "qualified",
"preconditions": [
{ "field": "crm_lead_contact_email", "operator": "is_not_null",
"message": "Email is required before qualification." }
]
}BindingRule is the imperative path. A binding rule attaches a named handler (a Java class registered with the platform or a plugin) to a command, with an explicit ruleType and execution order (sequence). Use it when the operation crosses systems, runs multi-step orchestration, calls external services, or implements rules that exceed the declarative vocabulary.
{
"commandCode": "crm:qualify_lead",
"ruleType": "handler",
"handlerClass": "leadQualifiedNotifier",
"sequence": 100,
"enabled": true
}Both modes share the same command pipeline (validation, authorization, transaction boundary, audit, event publish). The difference is who decides what happens inside the write: a JSON declaration, or a registered handler.
The two coexist by design. ExecutionConfig keeps simple commands honest — they stay readable in source control, AI tools can suggest changes safely, and reviewers can audit behavior without reading Java. BindingRule keeps the platform open — when business logic legitimately exceeds the schema, there is a sanctioned, registered, ordered extension point rather than an ad-hoc hook.
Inline bindingRules written into commands.json are documentation only. Real binding rules live in a dedicated bindingRules.json and are imported as first-class objects. A command that points to an unregistered handler fails the validator with a clear error code.
A rule of thumb for choosing between the two modes:
| Situation | Mode |
|---|---|
| Plain create / update / delete | ExecutionConfig |
| Status transition with simple guards | ExecutionConfig |
| Auto-fill a field from related data on save | ExecutionConfig defaults / computed |
| Send an email after a record is approved | BindingRule (AFTER_COMMIT) |
| Call an external ERP to reserve inventory | BindingRule (BEFORE_COMMIT, fail to abort) |
| Multi-step saga across two models | BindingRule with explicit ordering |
| Anything that needs to read a database it does not own | BindingRule |
The two modes are not exclusive on a single command. A command can declare ExecutionConfig for its core write and several BindingRule entries for pre/post side effects. The platform runs them in the documented phase order, and each binding rule sees the same command context.
DataSource binding
A block that renders data needs a dataSource. The contract supports four progressively dynamic modes:
| Mode | When to use | Shape |
|---|---|---|
| Static | Hard-coded options for small enums | { "type": "static", "data": [...] } |
| Model-bound | List or detail of a single model | "dataSource": "lead" (model code) |
| Named query | Reusable, server-defined query with parameters | { "type": "namedQuery", "code": "leadsByOwner" } |
| Dynamic | Parameterized API call, often dependent on other fields | { "type": "api", "url": "/api/.../{paramFromField}" } |
Named queries are the preferred mode for anything non-trivial. They are stored on the server, versioned with the plugin, and bound by code rather than URL. That lets the SQL change without every page being touched, and it gives the audit and security layers a stable identity to hang permissions on.
Dynamic data sources can reference other field values, the current user, the tenant, and URL parameters through a documented placeholder syntax. The renderer resolves placeholders before issuing the request and refuses to submit a request whose required parameters are still unresolved — another fail-fast surface.
A dynamic data source on a dependent picker, for example:
{
"name": "city",
"type": "select",
"dataSource": {
"type": "api",
"url": "/api/datasource/list?datasourceId=nq:citiesByProvince&province={province}",
"dependsOn": ["province"]
}
}When province changes, the renderer re-issues the request. When province is empty, the request is not issued at all and the picker stays disabled with a clear hint, rather than firing a half-formed URL and silently returning an empty list. The dependsOn declaration is what lets the renderer make that decision without guessing.
Field rendering contract
A field entry has a small, stable shape:
{
"name": "status",
"label": "$i18n:lead.status",
"type": "select",
"required": true,
"readonly": false,
"visibleWhen": "record.stage != 'closed'",
"dataSource": { "type": "dict", "code": "lead_status" }
}The renderer resolves each field in three steps:
- Type to component. The field
typemaps through a registry to a Smart component — text input, number, select, date, picker, rich text, attachment, sub-record reference, and so on. The same type yields the same component everywhere, which is what makes a field "look right" without per-page styling. - Metadata overlay.
required,readonly, andvisibleWhenare applied on top of the type.visibleWhenis an expression evaluated against the current record context. - Localized labels and help. Labels never appear as raw strings; they appear as i18n references that resolve at render time.
A field that names a model attribute is bound to it: validation, default values, and dictionary references come from the model, not from the page. Pages override only when they have a reason to.
Common field types and the components they resolve to:
| Field type | Component | Notes |
|---|---|---|
text, textarea | Text input / area | Length and pattern come from the model |
number, decimal, currency | Numeric input | Locale-aware formatting |
date, datetime | Date picker | Range support via paired fields |
select, multi-select | Dropdown | Backed by static, dict, or dynamic source |
dict | Dictionary picker | Bound by dict code, not by URL |
reference | Record picker | Cross-model lookup with type-ahead |
attachment | Upload + preview | Storage backend is platform-level |
rich-text | Rich editor | Sanitized; safe-for-render markup contract |
sub-record | Inline child form | Used for one-to-many composition |
The field type registry is open: plugins can contribute new types, and the same registration discipline applies — a registered component plus a config schema plus the contexts in which the field is allowed.
i18n and Localized text
Every user-facing string in a page schema is either an i18n key reference or a LocalizedText object.
"title": "$i18n:lead.list.title""title": { "en-US": "Leads", "zh-CN": "线索" }Resolution walks three layers:
- The page's own
LocalizedTextmap, if the value is an object literal. - The plugin's i18n bundles, keyed by
$i18n:reference. - The platform's shared i18n bundle (common verbs, error messages, generic labels).
A reference that cannot be resolved in any layer falls back to the key string itself — visible, ugly, and unmistakable in QA. Raw, hand-typed language strings inside schemas are not a style preference; they are a validator-rejected error class. The page golden gate exists in part to catch leaked language strings before they ship.
The same discipline applies to error messages, button labels, table column headers, dictionary item names, and confirmation prompts. Anything that a user can read in the UI is either an i18n reference or a LocalizedText map. The single place this rule does not apply is logging — log messages are operator-facing and live in source code.
Fail-fast semantics
The renderer treats the contract as a contract. The same principle applies at every layer:
- Unknown block types throw. No silent skip, no empty placeholder.
- Renamed block types throw with the new name. Migrations are loud.
- Missing required fields surface as field-level errors. Forms cannot submit; toasts are not a substitute for a labeled error.
- Invalid command codes throw at the toolbar level. Buttons that point nowhere are caught at design time.
- Unresolved dictionary codes throw on the column. A table column whose options never load is a bug, not a quirk.
This is deliberate. Silent fallback turns configuration errors into "weird UI" reports filed three sprints later. Failing fast turns them into stack traces filed immediately, by the person who introduced the error, while the change is still in their head.
The boundary of fail-fast is also explicit: it applies to configuration errors, the kind that should be caught before a record is touched. It does not mean every runtime exception bubbles to the user. Network errors, transient database failures, and authentication challenges are handled by the runtime in the normal way — retried where retry is safe, surfaced where it is not. The contract that fails fast is the schema contract, not the operational one.
Validation gates
There are two gates, and they do different jobs.
Static audit. A local script reads the plugin's pages.json, commands.json, bindingRules.json, permissions.json, and i18n bundles, and reports authoring mistakes: missing labels on a button's content, unbound dictionary codes on a table column, dangling references in bindingRules, missing required fields on a form's i18n strings, empty toolbars on pages that should have actions. It is fast enough to run in a pre-commit hook.
Platform import validator. When the plugin is imported through the platform's import API, the server resolves every reference against the live metadata, every command against the registered handlers, every dictionary code against published dictionaries, and every field against its model. It returns a structured success-or-failure result. This is the gate that decides whether a page can be served.
The audit and the validator overlap but are not identical. The audit catches what can be known statically; the validator additionally catches cross-object integrity that only the server can verify. A page that passes the audit but fails the validator is the audit's problem, but the right fix is always in the schema, never in the validator.
Common error classes the gates surface:
S-PAGE-LABEL— a button or field is missing a non-trivial label.S-PAGE-FORM-REQUIRED— a model field marked required is not mirrored in the page.S-PAGE-TABLE-DICT— a column references a dictionary code that does not exist.S-PAGE-BUTTONS— a toolbar or form-buttons block is empty.S-EXT-HANDLER— a binding rule points to an unregistered handler.
You will not memorize these. You will see them in your terminal when you break something, and the error message will tell you where to look.
There is a temptation, when the validator rejects a page, to make the validator more lenient. Resist it. The validator is not the inconvenience; the schema is wrong. The cost of a relaxed validator compounds — every plugin that passes through a weaker gate raises the cost of tightening it again later, because real pages now depend on the weaker behavior. Better to fix the page once than to weaken the contract permanently.
A few patterns make the gates productive rather than frustrating:
- Run the static audit in a pre-commit hook. Errors caught at commit time cost minutes; errors caught at import time cost an environment round-trip.
- Treat platform validator output as the source of truth. If the static audit says zero errors and the validator says ten, the audit is what is incomplete — file an issue for the audit, but fix the schema either way.
- When a validator error is unfamiliar, the error code is usually the fastest way to find documentation. Each
S-*code is documented in the operations reference. - When adding a new block or field type, also add the static audit rules and the validator rules that govern it. A registered primitive without enforcement is a primitive that will be misused.
The two gates are not redundant; they are layered defense. The audit gives developers fast feedback during authoring; the validator guarantees the runtime invariant that every served page is coherent.
Putting it together
A complete CRUD slice for a single model — list, form, detail — is roughly four artifacts and a few hundred lines of JSON:
- The model definition (fields, types, dictionaries, references).
- The pages JSON declaring
list,form, anddetail, each with its blocks. - The commands JSON declaring at minimum
create,update,delete, plus any state transitions. - The i18n bundles providing labels in every supported locale.
That is the full surface area. No controllers. No services. No view components. The platform handles routing, dispatch, validation, audit, and event publishing because the contract gives it the information to do so.
What changes between the simplest "stub" page and a production-grade page is not the artifact count — it is the depth of the configuration: visibility expressions, named queries instead of model-bound sources, computed defaults, multi-stage state transitions, binding rules for cross-system side effects. The vocabulary grows; the shape stays the same.
This is why the engine pays off at scale. A platform with a hundred plugins and a thousand pages does not have a hundred different page architectures. It has one architecture, instanced a thousand times, with the validators standing between sloppy schemas and shipping.
Enterprise extensions
The open-source DSL contract — pages, blocks, fields, commands, ExecutionConfig, simple BindingRule — covers the full surface most plugins need. The commercial edition extends the same contract along a few axes for scenarios that grow past the open-source vocabulary: richer BindingRule expressions and pre/post phases, additional data source types (including cross-model joins surfaced through the same namedQuery interface), cross-page and cross-field validation rules that run on commit, and collaborative editing features in the page designer. These extensions are additive: an open-source page schema continues to render unchanged when opened in the commercial edition.