Skip to Content
DocsBackend DevelopmentModel DevelopmentMetadata Annotation

Metadata Annotations

Requires metadata-starter as a dependency of your app for these annotations to take effect. softa-orm defines the annotations; metadata-starter contains the scanner and checker that read them and reconcile with sys_*. Without metadata-starter the annotations exist on your classes but no scanner consumes them — sys_* rows are never written and no DDL is generated.

Softa describes models, fields, option sets, option items, and indexes through Java annotations on the entity classes. A boot-time scanner reads these annotations, reconciles them with the sys_* catalog tables managed by metadata-starter, and (for packages in scanner-scope) converges the physical schema to the annotations — declared changes and hand-made drift alike, so a restart always ends with schema ≡ annotations for everything the scope owns.

Five annotations@Model / @Field / @Index live in io.softa.framework.orm.annotation; @OptionSet / @OptionItem live in io.softa.framework.base.annotation (so framework-level enums in softa-base can carry them without a module cycle):

AnnotationTargetsys_* table writtenPurpose
@Modelclasssys_modelDescribes an entity (table, business key, multi-tenancy, soft delete, etc.)
@Fieldfieldsys_fieldDescribes a column (label, type, length, required, relations, etc.)
@OptionSetenum classsys_option_setMarks an enum as a managed option set
@OptionItemenum constantsys_option_itemPer-constant display attributes
@Indexclass (@Repeatable)sys_model_indexDeclares a database index
@Data @EqualsAndHashCode(callSuper = true) @Model( label = "Customer", businessKey = {"code"}, description = "Customer master" ) @Index(indexName = "uk_customer_code", fields = {"code"}, unique = true) @Index(fields = {"status", "createdTime"}) public class Customer extends AuditableModel { @Field(label = "ID") private Long id; @Field(label = "Customer Code", required = true, length = 32) private String code; @Field(label = "Customer Tier") private CustomerTier tier; // enum → FieldType.OPTION (inferred) } @OptionSet(label = "Customer Tier") public enum CustomerTier { @OptionItem(label = "VIP Gold") GOLD("g"), // explicit: "VIP Gold" ≠ humanize("GOLD") SILVER("s"); // bare: label defaults to humanize("SILVER") = "Silver" @JsonValue private final String code; // itemCode = @JsonValue CustomerTier(String code) { this.code = code; } }

Inference rules (no annotation needed)

ConceptDerived fromOverride
modelNameclass simple name— (no override)
fieldNameJava field name— (no override)
optionSetCodeenum class simple name— (no override)
itemCode@JsonValue field value (fallback enum.name())— (no override)
tableNamesnake_case(modelName)@Model.tableName
columnNamesnake_case(fieldName)@Field.columnName
fieldTypeJava type via TypeInference (e.g. StringSTRING, enum→OPTION, List<enum>MULTI_OPTION, @Model POJO→MANY_TO_ONE, DTOFieldObject POJO→DTO)@Field.fieldType = FieldType.X (single value, no braces); OPTION / MULTI_OPTION cannot be written explicitly; TEXT (unbounded long text) is never inferred — declare it explicitly on a String field
index indexNameidx_<table>_<col>... / uk_<table>_<col>... for unique@Index.indexName

@ModelSysModel

@Model attributeTypeDefaultSysModel columnNotes
(class simple name)modelNameinferred, no override
labelString""labelempty → humanized class name (DeptInfo→“Dept Info”); i18n translations override by id
renamedFromString""renamedFromimmediately-prior model name for a rename (single-step, no chain) — see “Renames” below
tableNameString""tableNameempty → snake_case(modelName)
descriptionString""description≤512 chars, parse-time enforced (catalog column width); concise user-facing summary — design notes go in Javadoc
displayNameString[]{}displayNamelist-display defaults
searchNameString[]{}searchNamesearch-field defaults
defaultOrderString[]{}defaultOrdere.g. "createdTime:desc"
softDeletebooleanfalsesoftDeleterequires a deleted field; every read appends deleted = false (bypass via FilterControl.bypassSoftDelete()), and the starting value false is materialized into sys_field.default_value so the column DDL carries DEFAULT FALSE
activeControlbooleanfalseactiveControlrequires an active field; every read appends active = true unless the caller’s own filters name active (or FilterControl.bypassActiveControl() is set), so disabling retires a row from reads without deleting it; the starting value true is materialized into sys_field.default_value so the column DDL carries DEFAULT TRUE. Mutually exclusive with timeline (boot-rejected)
timelinebooleanfalsetimelineeffective-dated rows (see Timeline Model); mutually exclusive with activeControl — express period state as a versioned business field and terminate via setEndDate
idStrategyIdStrategyDB_AUTO_IDidStrategy
storageTypeStorageTypeRDBMSstorageType
versionLockbooleanfalseversionLockoptimistic-lock column; requires a version field, stamped onto every insert, with the starting value 0 materialized into sys_field.default_value so the column DDL carries DEFAULT 0
multiTenantbooleanfalsemultiTenantrequires a tenantId field on the class
copyablebooleantruecopyablefalse ⇒ copy APIs reject the model; UI hides Duplicate
projectionbooleanfalseprojectiontrue ⇒ read-only model over a table it does NOT own (another model’s table, or one created externally, e.g. by a BI pipeline). No DDL is ever generated for it; write APIs reject it; @Index on it is boot-rejected; RDBMS only. Every non-projection RDBMS model owns its table exclusively — two owners on one tableName fail at boot
dataSourceString""dataSourceempty → primary datasource
businessKeyString[]{}businessKeycomposite supported
partitionFieldString""partitionField
(scanner sets)appCodealways set by scanner / Studio
(DB auto)idprimary key

Audit fields (createdTime / createdBy / createdId / updatedTime / updatedBy / updatedId) come from AuditableModel and are not declared via @Field — they are auto-injected by DdlGenerator when the class extends AuditableModel.

@FieldSysField

@Field attributeTypeDefaultSysField columnNotes
(Java field name)fieldNameinferred, no override
(Java type)fieldTypeinferred via TypeInference
labelString""labelempty → humanized field name (deptId→“Dept Id”); i18n translations override by id
renamedFromString""renamedFromimmediately-prior field name for a rename (single-step) — see “Renames” below
descriptionString""description≤512 chars, parse-time enforced (catalog column width); concise user-facing summary — design notes go in Javadoc
fieldTypeFieldType[]{}fieldTypesingle value, no braces (e.g. fieldType = FieldType.MULTI_FILE); OPTION/MULTI_OPTION cannot be written explicitly
columnNameString""columnNameempty → snake_case(fieldName)
lengthint0length0 → type default: STRING/OPTION 64, MULTI_STRING/ORDERS 256, DOUBLE 24 (measurements), BIG_DECIMAL 32 (money); declare explicitly for anything else. On TEXT fields length is optional — purely an app-level guard (the column is unbounded). Legacy: MySQL renders STRING length > 16383 as TEXT (64KB bytes; prefer fieldType = TEXT)
scaleint0scale0 → type default: DOUBLE 2, BIG_DECIMAL 8 (DECIMAL scale)
requiredbooleanfalserequiredNOT NULL constraint
readonlybooleanfalsereadonlyUI hint
translatablebooleanfalsetranslatablei18n-aware column
copyablebooleantruecopyablefalse ⇒ value not carried over by copyById (business keys, credentials, runtime state)
unsearchablebooleanfalseunsearchableexcluded from default search
computedbooleanfalsecomputedrequires expression
expressionString""expressionAviatorScript
dynamicbooleanfalsedynamicnot physically stored
encryptedbooleanfalseencryptedat-rest encryption
autoSequencebooleanfalseauto_sequenceauto-fill from a sequence on INSERT when blank; STRING only (not dynamic/computed/id, RDBMS only); pairs with a sys_sequence row "<Model>.<field>" (missing row = insert fails, fail-closed). + readonly = strict system numbering (caller values rejected); without = caller values trusted (imports). Never carried on copy
maskingTypeMaskingType[]{}maskingTypesingle element
defaultValueString""defaultValue
relatedModelClass<?>Void.classrelatedModelClass ref (compile-checked), e.g. Foo.class; Void.class → inferred from POJO type; required for Long FK. Use relatedModelName (String) for cross-module/dynamic models
relatedModelNameString""relatedModelString fallback to relatedModel (cross-module/dynamic)
relatedFieldString""relatedFieldTO_ONE: always id — leave empty (a non-id value is rejected at boot; to store a business code make the related model code-as-id). ONE_TO_MANY: names the child FK column
onDeleteOnDelete[]{}on_deleteTO_ONE FK delete strategy: RESTRICT / CASCADE / SET_NULL; {}/unset = KEEP (default — do nothing). App-level (no DB FK). See “Delete strategy” below
joinModelClass<?>Void.classjoinModelM2M join model class; joinModelName (String) fallback
joinLeftString""joinLeft
joinRightString""joinRight
cascadedFieldString""cascadedFielddotted path, e.g. "owner.name"
filtersString""filtersfilter expression for relations
widgetTypeWidgetType[]{}widgetTypesingle-element override
(scanner sets)modelNamefrom enclosing @Model class
(scanner sets)optionSetCodederived from enum type when fieldType is OPTION/MULTI_OPTION
(scanner sets)appCode / id
(FK fixup post-init)modelId
(system-computed)relatedFieldTypephysical type of a TO_ONE FK column, mirrored from the referenced model’s id (+ mirrored length/scale) at reconciliation time; never declared on @Field
(not exposed via @Field)hiddenUI-only flag set via Studio

Copy field-selection contract (applies regardless of the copyable flag): ONE_TO_ONE FKs are always excluded — copying one would make two rows share an exclusively-owned related row, corrupting the 1:1 (or hard-failing on its unique index); dynamic fields (ONE_TO_MANY / MANY_TO_MANY / computed / cascaded) are excluded because they are not stored columns; MANY_TO_ONE stays copyable — a shared reference is exactly its semantics. Historical trap: the nonCopyablecopyable rename was done as a migration (V6), NOT via renamedFrom, because the rename inverts the value’s meaning — a value-preserving rename would have carried wrong values.

Delete strategy (onDelete)

On a MANY_TO_ONE / ONE_TO_ONE FK, onDelete declares what happens to the referencing rows when the referenced (“One”) row is deleted. Enforced application-level in ModelServiceImpl.deleteByIds — no physical DB FOREIGN KEY ... ON DELETE is ever emitted. Why app-level and never a real DB FK: soft delete is an UPDATE, invisible to a DB ON DELETE (the FK would simply never fire); a DB cascade bypasses permissions, change logs, audit stamping, soft-delete conversion and tenant scoping; a DB FK cannot express “count only deleted=false referrers”, “block regardless of tenant”, or “null only on hard delete”; and physical FK constraints sit outside the annotation-driven DDL governance (the scanner neither declares nor manages them). Strategies:

  • RESTRICT — block the delete if any live (deleted=false) referrer exists.
  • CASCADE — delete the referrers in the same transaction (each follows its own soft/hard delete). Rejected at boot if a soft-delete One would cascade to a hard-delete Many (a recoverable parent must not irreversibly delete children — make the Many soft-delete too, or use RESTRICT/SET_NULL).
  • SET_NULL — null the referrer FK; only on a hard delete of the One (no-op on soft delete, so a restore still resolves the link). Requires a nullable FK (required = false).
  • unset ({} / on_delete NULL) = KEEP (default) — the framework does nothing.

CASCADE soft/hard-delete matrix — the cascade on each Many follows the Many’s own delete mode (not the One’s); the one unsafe combination is rejected at boot:

One (referenced / parent)Many (referrer / child)CASCADE result
soft-deletesoft-deleteMany soft-deleted (both recoverable)
soft-deletehard-deleterejected at boot — a recoverable parent must not irreversibly delete children
hard-deletesoft-deleteMany soft-deleted
hard-deletehard-deleteMany hard-deleted

A CASCADE from a shared (non-multi-tenant) parent to a multi-tenant child is likewise rejected at boot — one delete would cascade across all tenants (use RESTRICT).

Runtime safety — a CASCADE / SET_NULL affecting more than MAX_BATCH_SIZE referrers per cascade level is rejected: referrerIds fetches at most MAX_BATCH_SIZE + 1 ids in one LIMIT-ed query, so an over-limit delete fails fast without loading the full set (bounded memory, no extra count). Large deletes are chunked to DEFAULT_BATCH_SIZE to bound the SELECT/DELETE statement + IN-clause size (same transaction — chunking bounds statement size, not lock duration).

For a OneToMany “delete parent → delete children”, put CASCADE on the child’s back-reference FK (the FK is the single source of truth; onDelete is not declared on ONE_TO_MANY).

Boot-time guards (fail-fast): onDelete is valid only on TO_ONE; SET_NULL requires a nullable FK; a cyclic / self-referential CASCADE is rejected (delete such hierarchies — org trees, BOM, category trees — in application code); a CASCADE chain deeper than MAX_CASCADE_DEPTH models is rejected (bounds recursion; the error names the full chain); and a CASCADE from a soft-delete parent to a hard-delete child, or from a shared parent to a multi-tenant child, is rejected (see the matrix above).

A timeline target is allowed: the inbound-FK strategy fires on entity deletion (deleteByIds, which removes all slices of the logical id — referencing FKs store that logical id, so RESTRICT counts / CASCADE deletes / SET_NULL nulls by it, no effective-date resolution involved); slice-level deleteBySliceId keeps the entity alive and deliberately does not trigger it.

Field-level overview for product/metadata authors: onDelete in Field metadata.

@OptionSetSysOptionSet

@OptionSet attributeTypeDefaultSysOptionSet columnNotes
(enum simple name)optionSetCodeinferred, no override
labelString""labeldisplay label; empty → humanized enum name (TenantStatus→“Tenant Status”)
renamedFromString""renamedFromimmediately-prior option-set code for a rename (single-step)
descriptionString""description≤512 chars, parse-time enforced (catalog column width); concise user-facing summary — design notes go in Javadoc
(scanner sets)appCode / id
(Studio toggle)active / optionItemsruntime aggregation

@OptionItemSysOptionItem

@OptionItem attributeTypeDefaultSysOptionItem columnNotes
(@JsonValue field value on enum)itemCodefallback to enum.name() when no @JsonValue
(enclosing enum simple name)optionSetCodeinferred
labelString""labeldefaults to humanized constant name (MULTI_FILE→“Multi File”); declare explicitly to customize. Omit when it equals the humanized name (and omit the whole @OptionItem if nothing else remains)
renamedFromString""renamedFromimmediately-prior item code for a rename (single-step)
descriptionString""description≤512 chars, parse-time enforced (catalog column width); concise user-facing summary — design notes go in Javadoc
sequenceint-1sequence-1 → use ordinal() + 1
parentItemCodeString""parentItemCodehierarchy
itemToneOptionItemTone[]{}itemTonesingle element
itemIconOptionItemIcon[]{}itemIconsingle element
(scanner sets)appCode / id / optionSetId
(Studio toggle)active

@IndexSysModelIndex

@Index is @Repeatable — stack multiple declarations on one @Model class.

@Index attributeTypeDefaultSysModelIndex columnNotes
(enclosing class)modelNameinferred
indexNameString""indexNameempty → auto-derived idx_<table>_<col>... / uk_<table>_<col>... for unique; index names are globally unique (≤ 60 chars, boot-enforced)
fieldsString[]requiredindexFieldscamelCase Java field names, not column names
uniquebooleanfalseuniqueIndex
messageString""messageunique-only: user-facing message shown on a uniqueness violation (has its own i18n key)
(scanner sets)appCode / id
(FK fixup post-init)modelId

Note: @Model.businessKey does not auto-create a UNIQUE index. Multi-tenant models typically want UNIQUE (tenant_id, businessKey...) which has tenant-aware semantics not expressible by @Index alone — declare such indexes explicitly:

@Index(fields = {"tenantId", "code"}, unique = true)

Renames (renamedFrom)

The scanner’s diff is keyed by modelName / fieldName / optionSetCode / itemCode, so an undeclared rename looks like “drop old + add new” — and under an active scanner-scope the convergence pass executes exactly that: the new column is added empty, and the old column, no longer declared by anything, is dropped together with its data in the same boot. Nothing arrives in the new column.

Declare the immediately-prior name instead:

@Model(renamedFrom = "OldCustomer") // model rename public class Customer extends AuditableModel { @Field(renamedFrom = "customerName") // field rename private String name; }

The DiffEngine then pairs the two sides into a single rename modification, auto-executes CHANGE COLUMN (field) / ALTER TABLE … RENAME TO (model), and updates the sys_* row in place (id preserved) — data is carried, not divorced. A model rename cascades onto its fields and indexes, so it shows no field churn. @OptionSet / @OptionItem support the same attribute.

Rules and guards:

  • renamedFrom is a single String — the immediately-prior name only (single-step, no chain). A skipped-version chain needs a manual migration.
  • Declaring a prior name that is still a live field/model, or two siblings claiming the same prior name, fails at parse time.
  • “Both the new and the prior name already exist” fails fast — resolve the half-applied rename manually.
  • An @OptionItem code rename that also carries business-data UPDATEs still needs a hand-written migration.

scanner-scope (which packages the scanner manages)

scanner-scope is a list of regex patterns full-matched against each @Model / @OptionSet class’s package name. "*" (sole entry) = all packages; empty / unset = manage nothing. It should never be non-empty in production — in production, Studio / connector publish applies the app-scoped design catalog instead.

# application-dev.yml system: metadata: scanner-scope: - "*" # manage every package; on a shared dev DB, narrow to # your own packages, e.g. ["io\\.acme\\.app.*"]
system.metadata.scanner-scopeScanner runsDDL executionDrift detection
["*"]Boot-time, eager, all packagesPhysical convergence (see below): every owned table converges to its annotations on every boot — CREATE / ADD / MODIFY (narrowing included) / declared RENAME, plus DROP of undeclared columns and indexesCode-less catalog roots named in a WARN with copy-paste SQL; the drift audit reports the residual (projections, undeclared tables)
["io\\.acme\\.foo.*", …]Boot-time, in-scope packages onlySame convergence, in-scope models only — out-of-scope tables are never touchedn/a
empty / unset (default, prod)n/an/aMetadataAnnotationChecker runs post-boot on a virtual thread; logs WARN if code-vs-DB drift detected — report-only, nothing executes

On a shared dev database, give each developer a narrow scope (their own packages) so the scanner only reconciles — and converges — the Java packages they are actively changing. Scope is per-package, not per-class; app identity is still app_code, and physical table-name collisions remain a database-level concern.

Catalog row policy

The catalog is an aggregate: sys_model / sys_option_set are the roots, sys_field / sys_model_index / sys_option_item their attributes.

ChangeApplied
Root added / modified
Attribute added / modified / removed, on a root whose class is present✅ — the annotations own the root’s attribute set
Root removed (a catalog row with no @Model / @OptionSet class)❌ under every scope, ["*"] included — the root and its attribute rows are left untouched; ["*"] logs a WARN naming them with copy-paste DELETE SQL

A code-less root is a first-class state: Studio no-code and seed-authored models never have a Java class, and nothing in the catalog records row ownership — so “orphan” and “deliberately code-less” cannot be told apart, and auto-deleting would silently destroy hand-authored definitions on every boot. Note the contrast with the physical schema below: an undeclared column on a table the scope owns has no legitimate author (the owner’s annotations are its single source of truth) and is converged away, while a code-less root may be someone’s deliberate definition and is only ever named in the WARN.

DDL execution policy (physical convergence)

With an active scanner-scope, the physical table of every in-scope owned model is a pure function of the annotations — every boot converges it, declared changes and hand-made drift alike:

State (annotation vs physical)DDLExecuted?
New @Model / table physically missingCREATE TABLE (with inline indexes); a pre-existing table is adopted column-by-column instead
New @Field / declared column physically missingADD COLUMN
Changed @Field attribute (type / length / required / default / comment)MODIFY COLUMN to the declared shape
Physical type/width mismatch — widen, narrow, incomparableMODIFY COLUMN to the declared shape — the declaration is the truth, and a non-empty scanner-scope is by definition non-production
Removed @Field / undeclared physical columnDROP COLUMN
Undeclared physical indexDROP INDEX (primary-key backing indexes excluded)
New @Index / declared index physically missing / definition changedADD INDEX / rebuild (DROP + ADD)
Removed @ModelDROP TABLE❌ — the code-less root keeps its rows and its table; the ["*"] WARN prints the cleanup SQL
Bare tableName change while the old table physically existsboot fails with instructions — creating the new table would silently divorce the data, and the planner never guesses
Whole undeclared tables; anything on a projection❌ untouched — ownership cannot be proven (another app_code, a legacy table) / the table belongs to the owner; the drift audit is the reporting channel

If physical introspection fails, the boot degrades to conservative metadata-only planning: additive changes and declared renames auto-execute; every destructive verb defers to a warn-only copy-paste SQL block — without facts, drift and intent are indistinguishable. Destructive convergence can never reach production, because production runs the empty scope (checker-only): the gate is the existing scanner-scope posture, not a separate switch.

Projection models are outside this table entirely: a model declared @Model(projection = true) (a read-only model over a table it does not own — another model’s table, or one created externally, e.g. by a BI pipeline) generates no DDL for any change. Its sys_* rows still reconcile, but the table’s shape belongs to its owning model or the external process. One table has ONE non-projection owner — a second owner fails at boot, which makes a fresh-database bootstrap deterministic (exactly one CREATE per table) and turns an accidental tableName collision into a boot error instead of a silent table merge. The physical drift audit checks a projection one-way (its declared columns must exist; the owner’s other columns/indexes are never reported as undeclared), and a physically missing projection table logs an ERROR — never a boot failure, never auto-created. Convention for in-app sharing: repeat the owner’s column declarations verbatim for the columns the projection exposes, and declare everything else dynamic.

Catalog self-bootstrap (the sys_* tables’ own schema)

The five boot-read catalog tables (sys_model, sys_field, sys_option_set, sys_option_item, sys_model_index) have no row-level “last applied state” — the rows recording every other model’s state live inside them. On every boot with a non-empty scanner-scope, the scanner therefore reconciles them physically, from their own annotations, before the strict catalog read:

  • table missing → CREATE TABLE — a fresh, empty database bootstraps with no baseline SQL at all;
  • column missing → ADD COLUMN — a catalog-column addition in a new framework version converges on existing databases without a hand-written migration;
  • declared renamedFrom with the prior column present → CHANGE COLUMN (data carried); both old and new present → boot fails with instructions;
  • physically narrower than declared (bounded widths only) → widening MODIFY COLUMN; anything wider / incomparable / undeclared is left untouched by this stage — it runs before the diff exists and possibly under a narrow scope that does not manage the catalog. When the catalog packages are in scope, the main convergence pass later in the same boot eliminates those like any other in-scope drift; otherwise the physical drift audit reports them.

The whole boot DDL window (catalog reconcile → strict read → diff → DDL → row writes) is serialized across instances by a database session lock (MySQL GET_LOCK / PostgreSQL advisory lock, 60s wait budget), so replicas booting the same database never race their DDL.

What still needs a hand-written migration: backfill UPDATEs that give an added column real values on rows the scanner does not manage, destructive changes on out-of-scope tables, and environments running with an empty scanner-scope — there nothing auto-applies, catalog included.

Metadata identity (app_code)

There is no ownership tier column on the sys_* catalog. The annotation lane and the Studio no-code lane reconcile the same rows, matched by business key (modelName / fieldName / optionSetCode / itemCode, plus renamedFrom) — a same-key row is updated in place, never duplicated per channel.

Every runtime declares system.app-code in application.yml (mandatory when metadata-starter is active; fail-fast at boot). All swept sys_* rows carry app_code, stamped server-side on every write path (scanner, Studio envelope, plan/apply) — wire values are never trusted. Signed Studio calls carry the target appCode and the runtime rejects mismatches. Multiple apps can safely share one database: rows are matched per app_code, so shared databases never cross-link catalogs.

Last updated on