Slice 4 — extending: adding a custom field per tenant
xly is sold to many customers. Each customer wants to track a few things the framework doesn't know about — a custom code, an internal note, a classification tag. The supported way to add such a field, without forking the codebase or altering shared metadata, is to insert a row into a customization table whose contents merge over the base form at runtime.
This slice traces that mechanism. It's the first chapter where we cover xly's per-tenant overlays — the customization model that makes the single-codebase / many-customers SaaS work.
Three levels of customization
xly stacks three distinct customization tables on top of every base form. Each lives at a different scope and answers a different question.
| Table | Scope | Layer overridden | Rows in dev |
|---|---|---|---|
gdsconfigformpersonalize |
per tenant (sBrandsId + sSubsidiaryId) |
form-master — overrides sSqlStr / sWhere / sOrder for the whole form |
18 |
gdsconfigformcustomslave |
per tenant | form-slave — adds, hides, or replaces individual fields on the form | 0 |
gdsconfigformuserslave |
per user | form-slave — per-user field tweaks (column order, hidden columns) | 9 |
The base form is gdsconfigformmaster + gdsconfigformslave and is the
system default — what every tenant sees unless overridden. The three
tables above are overlays, applied (and merged) at request time by
the runtime.
This slice focuses on the middle row — gdsconfigformcustomslave — the
canonical "add a custom field for tenant X" channel.
What the slice would look like (a worked example)
Imagine a tenant 山东星海印务 wants to add a "客户内部编码" (Customer
Internal Code) field to the customer-list form. They (or an
implementer) does this without touching gdsconfigformslave. Instead:
- Open the BACK module that edits
gdsconfigformcustomslaverows (one of the system-management screens — likely界面显示内容配置— needs verification by clicking through BACK). - Add a new row with:
-
sParentId= the form'ssId(same form the base slaves point to) -
sName = 'sInternalCode'(the field's column name) sChinese = '客户内部编码'-
sControlName = '文本框'(text input) -
sBrandsId+sSubsidiaryId= the tenant's IDs
-
- Optionally add a column to the underlying physical table (manual
schema migration — the framework does not automatically
ALTER TABLE). Otherwise the field exists only on the form and would have nothing to bind to on save.
The next time anyone in that tenant loads the form, they see the extra column. Every other tenant continues to see the unmodified base form.
Empty in dev — important caveat.
gdsconfigformcustomslavehas zero rows inxlyweberp_saas_ai. The table is wired into the framework but no tenant in this dev DB has used it. The trace below is therefore code-derived; the live observation pass is open.
How the runtime merges
The frontend doesn't see three tables — it sees one merged form. The framework does the merge through two database views that join the base slave and the override slave with the form-master:
-
gdsconfigformslavemasterview— joinsgdsconfigformmasterwith the basegdsconfigformslave. Always loaded. -
gdsconfigformcustomslavemasterview— joinsgdsconfigformmasterwith the customgdsconfigformcustomslave. Loaded only for tenants that have rows.
The service layer reads both, merges by sName, and returns a single
slave list to the SPA. If a tenant's gdsconfigformcustomslave row has
the same sName as a base slave, it overrides; if it's a new sName,
it's appended. Hidden / removed cases are handled via bVisible.
The form-level overrides in gdsconfigformpersonalize are merged in a
similar way, but at the form-master level: at request time, the
runtime calls
configformpersonalizeService.getConfigformpersonalize(sBrandsId,
sSubsidiaryId, gdsconfigformmasterId) (you can see the call from the
Slice-1 trace —
BusinessBaseServiceImpl.java:325-327) and, if it
returns a row, swaps its sConfigSqlStr / sConfigWhere /
sConfigOrder over the base form's sSqlStr / sWhere / sOrder.
So the merge order, from base to most-specific, is:
gdsconfigformmaster (system default for the form)
↓ overlaid by
gdsconfigformpersonalize (per-tenant whole-form override)
↓ then base slaves
gdsconfigformslave (system default fields)
↓ overlaid / extended by
gdsconfigformcustomslave (per-tenant fields)
↓ optionally further tweaked by
gdsconfigformuserslave (per-user view preferences)
This is the "no-FK, semantic-FK reality" applied to the framework's own
configuration: there are no foreign keys enforcing that
gdsconfigformcustomslave.sParentId actually exists in
gdsconfigformmaster.sId. Orphan rows are possible and would be silently
ignored at merge time. A maintainer audit script that flags such orphans
is on the Maintainer Reference's
TODO list.
Why it works without code changes
The end-customer never asks an engineer for a new column. They open the BACK builder, add the row, the field appears in FROUNT for their tenant only. The system's other tenants are untouched. That single-codebase property is what xly's data-driven thesis (Concepts → Thesis) buys — at the cost of the runtime cost of merging metadata on every request, plus the schema bloat of three customization tables that most forms never use.
Concepts this slice introduces
- Customization layers (new concept page) — the three-overlay model above; how the merge happens; when each layer applies.
-
Schemaless extension limits — the framework will render extra
fields, but it does not (and cannot)
ALTER TABLE. Custom fields beyond what the base table provides require either (a) coordinated manual schema migration on a "wide" base table, or (b) a JSON column pattern. This slice's example assumes (a). Slice 5 (per-customer SQL overrides) covers (b) for the cases where structural change is needed.
Reference entries this slice exercises
- Builder: how to define a form — add a section on the override path: same recipe, different table.
-
Maintainer: the runtime — the
merge-three-layers procedure happens inside
BusinessBaseServiceImplandBusinessGdsconfigformsServiceImpl. Worth a dedicated paragraph.
Open verification items
-
Find the live BACK page that edits
gdsconfigformcustomslave. Most likely界面显示内容配置from the sidebar; confirm by clicking in BACK and checking which table the save endpoint writes to. -
Trace the merge code. Locate the exact join logic — is the merge
done in MyBatis (the two views) or in Java
(
BusinessGdsconfigformsServiceImpl.getFormSlaveData+getFormCustomSlaveData, called inBusinessBaseServiceImpl.java:246-248)? Both look plausible from the source we've seen. -
bVisible = falsesemantics. Does settingbVisible = falseon agdsconfigformcustomslaverow hide an existing base field (an override-to-remove pattern), or only suppress the override itself? Likely the former, but worth confirming. -
A real example. Find one tenant's actual
gdsconfigformcustomslaverows in production (not in this empty dev DB) and use it as a worked example here.