Skip to Content
DocsBackend DevelopmentModel DevelopmentMetadata Annotation

Metadata Annotations

Softa maintains its sys_* catalog tables through two coexisting paths:

  1. Annotations (this page) — code-first. Declare metadata on Java entity classes; a boot-time scanner reads the annotations, reconciles them with the sys_* tables, and applies matching DDL in dev mode. Best for the baseline shipped with your codebase — platform / framework models versioned alongside the source, gated by code review and CI/CD.
  2. Studio (visual designer) — config-first. A visual workbench writes the same sys_* rows through a WorkItem → Version → Deployment workflow. Best for tenant customizations and business-team-owned configuration that need to change without a redeploy. See the Studio User Guide.

The two paths never clobber each other: every sys_* row carries an Ownership tag, and the annotation scanner only reads / writes PLATFORM_MAINTAINED rows.

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.

Five annotations, all in io.softa.framework.orm.annotation:

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( labelName = "Customer", businessKey = {"code"}, description = "Customer master" ) @Index(name = "uk_customer_code", fields = {"code"}, unique = true) @Index(fields = {"status", "createdTime"}) public class Customer extends AuditableModel { private Long id; @Field(labelName = "Customer Code", required = true, length = 32) private String code; @Field(labelName = "Customer Tier") private CustomerTier tier; // enum → FieldType.OPTION (inferred) } @OptionSet(name = "Customer Tier") public enum CustomerTier { @OptionItem(itemName = "VIP Gold") GOLD("g"), @OptionItem(itemName = "Silver") SILVER("s"); @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)@Field.fieldType = { ... } (single element); OPTION / MULTI_OPTION cannot be written explicitly
index indexNameidx_<table>_<col>... / uk_<table>_<col>... for unique@Index.name

@ModelSysModel

Business semantics of each attribute: see Model Metadata.

@Model attributeTypeDefaultSysModel columnNotes
(class simple name)modelNameinferred, no override
labelNameString""labelNameempty → i18n key model.{modelName}.label
tableNameString""tableNameempty → snake_case(modelName)
descriptionString""description
displayNameString[]{}displayNamelist-display defaults
searchNameString[]{}searchNamesearch-field defaults
defaultOrderString[]{}defaultOrdere.g. "createdTime:desc"
softDeletebooleanfalsesoftDelete
softDeleteFieldString"deleted"softDeleteFieldeffective only when softDelete = true
activeControlbooleanfalseactiveControladds active gate column
timelinebooleanfalsetimelineeffective-dated rows (see Timeline Model)
idStrategyIdStrategyDB_AUTO_IDidStrategy
storageTypeStorageTypeRDBMSstorageType
versionLockbooleanfalseversionLockoptimistic-lock column
multiTenantbooleanfalsemultiTenantrequires a tenantId field on the class
dataSourceString""dataSourceempty → primary datasource
businessKeyString[]{}businessKeycomposite supported
partitionFieldString""partitionField
serviceNameString""serviceNamemicroservice routing key — see softa-web/README
(scanner sets)appIdalways set by scanner / Studio
(DB auto)idprimary key
(scanner sets)ownershipPLATFORM_MAINTAINED for scanner writes

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

Business semantics of each attribute and the full field-type catalog: see Field Metadata.

@Field attributeTypeDefaultSysField columnNotes
(Java field name)fieldNameinferred, no override
(Java type)fieldTypeinferred via TypeInference
labelNameString""labelNameempty → i18n key
descriptionString""description
fieldTypeFieldType[]{}fieldTypesingle-element override; OPTION/MULTI_OPTION cannot be written explicitly
columnNameString""columnNameempty → snake_case(fieldName)
lengthint0length0 → type-specific default; STRING / DECIMAL precision
scaleint0scaleDECIMAL scale
requiredbooleanfalserequiredNOT NULL constraint
readonlybooleanfalsereadonlyUI hint
translatablebooleanfalsetranslatablei18n-aware column
nonCopyablebooleanfalsenonCopyableexcluded from copy()
unsearchablebooleanfalseunsearchableexcluded from default search
computedbooleanfalsecomputedrequires expression
expressionString""expressionAviatorScript
dynamicbooleanfalsedynamicnot physically stored
encryptedbooleanfalseencryptedat-rest encryption
maskingTypeMaskingType[]{}maskingTypesingle element
defaultValueString""defaultValue
relatedModelString""relatedModelempty → inferred from POJO type; required when Java type is Long storing an FK id
relatedFieldString""relatedFieldempty → "id"
joinModelString""joinModelM2M join table
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)appId / id / ownership
(FK fixup post-init)modelId
(not exposed via @Field)hiddenUI-only flag set via Studio

@OptionSetSysOptionSet

Runtime behavior, caching, and API shape: see Option Sets.

@OptionSet attributeTypeDefaultSysOptionSet columnNotes
(enum simple name)optionSetCodeinferred, no override
nameString""namedisplay label; empty → i18n key
descriptionString""description
(scanner sets)appId / id / ownership
(Studio toggle)deleted / optionItemsruntime aggregation

@OptionItemSysOptionItem

Display attributes (itemTone, itemIcon) and API response shape: see Option Sets.

@OptionItem attributeTypeDefaultSysOptionItem columnNotes
(@JsonValue field value on enum)itemCodefallback to enum.name() when no @JsonValue
(enclosing enum simple name)optionSetCodeinferred
itemNameString""itemNameempty → use itemCode as fallback
descriptionString""description
sequenceint-1sequence-1 → use ordinal() + 1
parentItemCodeString""parentItemCodehierarchy
itemToneOptionItemTone[]{}itemTonesingle element
itemIconOptionItemIcon[]{}itemIconsingle element
(scanner sets)appId / id / ownership / optionSetId
(Studio toggle)active

@IndexSysModelIndex

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

@Index attributeTypeDefaultSysModelIndex columnNotes
(enclosing class)modelNameinferred
nameString""namedisplay title; auto-derived from fields when empty
name (or auto-derived)indexNameidx_<table>_<col>... / uk_<table>_<col>... for unique
fieldsString[]requiredindexFieldscamelCase Java field names, not column names
uniquebooleanfalseuniqueIndex
(scanner sets)appId / id / ownership
(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)

Row ownership (Ownership enum)

Every row in sys_model / sys_field / sys_option_set / sys_option_item / sys_model_index carries an ownership column (io.softa.framework.orm.enums.Ownership):

ValueWriterTenants may modify?
PLATFORM_MAINTAINEDScanner (from @Model / @Field / @OptionSet / @OptionItem / @Index)
PLATFORM_DEFAULTStudio Open API / DML seed (for framework enums like Language that cannot carry @OptionSet)✅ per-row override
TENANT (default)Studio UI / Open API

The scanner reads / writes are filtered with WHERE ownership = 'PLATFORM_MAINTAINED', so platform defaults and tenant customizations are never clobbered by an annotation reconcile.

See Ownership.java javadoc for the full merge-rule contract.

Last updated on