Entity Annotations
vyuh_entity_annotations is a pure-Dart package of const-friendly annotations for the Vyuh Entity System. You mark client model classes with @Entity and server model classes with @ServerEntity, and the companion vyuh_entity_generator reads those annotations to emit .entity.dart and .server.dart part files containing typed FieldDefinition<T>s, an API base class, default layouts, a default form, an EntityConfiguration<T>, and (on the server) an EntityCrudConfig<T> plus an EntityCrudDescriptor<T>.
Why a separate annotations package?
vyuh_entity_annotations has zero dependency on Flutter, source_gen, or analyzer. You can import it from any Dart package — including server code, isolates, and test fixtures — without pulling in the generator toolchain. The generator itself only runs as a dev_dependency in packages that contain annotated models.
Installation
Authenticate with pub.vyuh.tech first
`vyuh_entity_annotations` is hosted on the private pub.vyuh.tech registry. Before running pub get, register a Bearer token issued by Vyuh Technologies based on your plan.
Run the one-time setup:
dart pub token add https://pub.vyuh.techDon't have a token yet? Email ask@vyuh.tech to request one. For full details (CI, Docker, rotation, troubleshooting), see the Pub Token Setup guide.
dependencies:
vyuh_entity_annotations:
hosted: https://pub.vyuh.tech
version: ^3.3.0In practice you almost always pair this with vyuh_entity_generator as a dev dependency — see the Generator guide.
Cheat sheet — annotation to generated artifact
| Annotation | Where it goes | What the generator emits |
|---|---|---|
@Entity | Client model class extending $<Cls>Base | $<Cls>Base, <Cls>Fields, $<Cls>Api, $<cls>TableLayout, optional $<cls>KanbanLayout, $<Cls>DetailLayout, $<cls>Form, $<cls>Metadata, $<cls>Routing, $<cls>WorkspaceConfig, <cls>Config |
@Field / @Field.named | Member of an @Entity class | One typed FieldDefinition<T> static on <Cls>Fields, one column on $<cls>TableLayout, one form node on $<cls>Form, contributes to detail layout |
@ServerEntity | Server model class extending $<Cls>Base | $<Cls>Base (server flavour), $<cls>ServerCrudConfig (EntityCrudConfig<T>), $<cls>ServerDescriptor (EntityCrudDescriptor<T>) |
EntityCapability.* | @Entity / @ServerEntity capabilities | Mixin set on the base class; capability-specific fields, API mixins, detail tabs, lifecycle hooks |
EntityVisibility.* | @Entity.visibility | Annotation-side visibility vocabulary; generator maps it to runtime UIVisibility entries on EntityMetadata.visibility |
EntityTag.group/.feature | @Entity.tags | Tag list on EntityMetadata.tags; queryable at runtime |
Relationship.manyToMany/oneToMany | @Entity.relationships | RelationshipApiMixin<T> + getLinked*/link*/unlink* methods on the API; one $<Cls><Rel>RelationshipLayout detail tab per relationship |
ServerRelationship.manyToMany/oneToMany | @ServerEntity.relationships | Relationship metadata folded into the generated server CRUD descriptor/config |
EntityNavigation + AuthorizeEntity | @Entity.navigation | EntityRouting<T> with path, builder, and per-operation permissions/authorize rules |
EntityUILayouts + TableLayout | @Entity.layouts | EntityLayouts<T> with list, details, analytics; TableLayout drives $<cls>TableLayout columns |
EntityUIActions + presets | @Entity.actions | EntityActions<T> with inline/header/collection/menu; capability-derived defaults when not explicit |
Authorized(Type, authorize:) | Inside actions / details / analytics | Type entry plus authorize expression flattened to permission strings at the legacy surface |
Authorize.* / AuthorizeRule | AuthorizeEntity, AuthorizeField, etc. | Const expression evaluated against AuthorizationContext at runtime |
EntityOwnership.* | @Entity.ownership (reserved) | Owner-extractor wired into Authorize.self() evaluation |
EntityI18n | @Entity.i18n | nameResolver, pluralNameResolver, descriptionResolver, and per-field labelResolver functions reading from your translations source |
ServerHooks | @ServerEntity.hooks | Handler replacements (onCreate/Update/Delete) and lifecycle hooks (validate*, before*, after*) wired into the generated EntityCrudConfig |
TenantConfig.* | @ServerEntity.tenant | Captured for scope emission; when the builder is configured with a tenant-scope factory, it emits scope: on EntityCrudConfig<T> |
@Entity — the client annotation
@Entity marks an entity model class for client-side code generation. A single annotation wires identity, capabilities, visibility, navigation, layouts, editor, actions, relationships, and localization.
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:vyuh_entity_annotations/vyuh_entity_annotations.dart';
import 'package:vyuh_entity_system_ui/vyuh_entity_system_ui.dart' hide EntityNavigation;
part 'area.g.dart';
part 'area.entity.dart';
@Entity(
schema: 'lms',
identifier: 'areas',
name: 'Area',
pluralName: 'Areas',
description: 'Manage physical areas and zones in the facility',
icon: FluentIcons.location_24_regular,
category: lms, // const EntityCategory
menuCategory: masterData, // const MenuCategory
priority: 1,
alternateIdentifiers: ['area'],
capabilities: {EntityCapability.draftable()},
visibility: EntityVisibility.all,
navigation: EntityNavigation(
prefix: '/lms/areas',
authorize: AuthorizeEntity(
list: AuthorizeRule(Authorize.permission('lms.areas.view')),
create: AuthorizeRule(Authorize.permission('lms.areas.create')),
view: AuthorizeRule(Authorize.permission('lms.areas.view')),
edit: AuthorizeRule(Authorize.permission('lms.areas.update')),
),
),
layouts: EntityUILayouts(
table: TableLayout(columns: [
TableColumn('name', widthFactor: 0.25, minWidth: 180),
TableColumn('areaCategoryId', widthFactor: 0.20, minWidth: 150),
TableColumn('capacity', widthFactor: 0.10, minWidth: 100),
]),
),
)
@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false)
class Area extends $AreaBase {
// ... @Field-annotated members below
}Identity slots
| Parameter | Required | Type | Purpose |
|---|---|---|---|
schema | yes | String | Database schema (e.g. 'lms', 'sso'). Drives RPC calls and route paths. |
identifier | yes | String | Primary identifier for API paths and lookups (e.g. 'areas'). |
name | yes | String | Singular display name (English fallback). |
pluralName | yes | String | Plural display name (English fallback). |
description | yes | String | Short description for menus and tooltips (English fallback). |
alternateIdentifiers | List<String> | Alternate identifiers for lookups (e.g. ['area'] for 'areas'). Default const []. | |
icon | Object? (IconData) | Const IconData for menus, tabs, and headers. | |
category | Object? | Const EntityCategory instance or a top-level function tear-off returning one. | |
menuCategory | Object? | Const MenuCategory instance or a top-level function tear-off returning one. | |
priority | int | Display priority in menus and listings (lower = higher priority). Default 0. | |
defaultSortField | String? | Default sort field for list queries. | |
fieldAliases | Map<String, String> | Aliases conceptual fields ('name') to the actual SQL columns (e.g. 'batch_number'). Default {}. |
Visibility
EntityVisibility is a sealed class with named constructors. The set decides which surfaces (routes, menu, search, dashboard) expose the entity. An empty set means invisible everywhere — useful for junction tables or API-only entities that an embedded shell mounts via getConfig<T>().buildRoutes().
// Visible everywhere
visibility: EntityVisibility.all,
// Routes + search only
visibility: {EntityVisibility.route(), EntityVisibility.search()},
// Menu with a specific sort order, search with extra keywords
visibility: {
EntityVisibility.route(),
EntityVisibility.menu(sortOrder: 2),
EntityVisibility.search(additionalSearchTerms: ['zone', 'cleanroom']),
EntityVisibility.dashboard(),
},
// Invisible — embedded inside another shell
visibility: const {},| Constructor | Effect |
|---|---|
EntityVisibility.route() | Generates GoRouter routes (list, detail, create, edit). Required for any URL access. |
EntityVisibility.menu({int sortOrder = 0}) | Shows in the navigation menu under the entity's menuCategory; generator also emits route visibility because menu entries must navigate. |
EntityVisibility.search({List<String> additionalSearchTerms = const []}) | Discoverable in command palette / global search; extra keywords for fuzzy match. |
EntityVisibility.dashboard() | Eligible for dashboard widgets (analytics tiles, quick-access cards). |
EntityVisibility.all (Set<EntityVisibility>) | Convenience constant: route + menu + search + dashboard. |
Capabilities
EntityCapability is a sealed class with named factory constructors. Each capability adds mixins, lifecycle hooks, or API behaviours to the generated code. Use a Set<EntityCapability> so each capability appears at most once.
| Capability | Effect |
|---|---|
EntityCapability.auditable() | Adds Auditable mixin to $<Cls>Base with createdAt, updatedAt, createdBy, updatedBy fields and an audit-info card in the default detail layout. |
EntityCapability.versioned() | Adds the Versionable mixin to $<Cls>Base, the VersioningApiMixin<T> to $<Cls>Api, and version/audit detail tabs. Implies auditable. |
EntityCapability.draftable() | Adds the Draftable mixin to $<Cls>Base, the DraftApiMixin<T> to $<Cls>Api, and draft-aware editor behaviour. Implies versioned. |
EntityCapability.cached({Duration? staleDuration, int? maxEntries}) | Configures the API's cachingPolicy getter. Client-only — ignored by the server generator. |
capabilities: {
EntityCapability.draftable(),
EntityCapability.cached(staleDuration: Duration(minutes: 5)),
}Implication chain
draftable -> versioned -> auditable. You only need to declare the highest capability you want; the implied ones are added automatically by the generator's ResolvedEntity.isAuditable / isVersionable derived flags. Listing both draftable() and auditable() is harmless but redundant.
Navigation and authorization
@Entity.navigation accepts either an EntityNavigation const instance or a top-level function tear-off returning EntityRouting<T>. The structured form is the most common.
navigation: EntityNavigation(
prefix: '/lms/batches',
authorize: AuthorizeEntity(
list: AuthorizeRule(Authorize.permission('lms.batch.view')),
create: AuthorizeRule(Authorize.permission('lms.batch.create')),
edit: AuthorizeRule(
Authorize.allOf([
Authorize.permission('lms.batch.modify'),
Authorize.anyRole(['operator', 'qa_lead']),
]),
fallback: AuthorizeFallback.placeholder(
title: 'Restricted',
message: 'Only operators or QA leads can edit batches.',
),
),
fallback: AuthorizeFallback.placeholder(message: 'Access denied.'),
),
)Operation slots on AuthorizeEntity are list, create, view, edit, dashboard, statusChange, delete. Each slot is an AuthorizeRule — an Authorize expression plus an optional per-rule AuthorizeFallback. The bundle-level fallback applies when an individual rule has none.
Authorize expressions
Authorize is a sealed class. All constructors are const.
// Atoms
Authorize.permission('lms.batch.view')
Authorize.role('qa_lead')
Authorize.userGroup('night_shift')
Authorize.self() // requires EntityOwnership on @Entity
Authorize.self(as: 'submitter') // multi-relation ownership
// List sugar
Authorize.anyPermission(['x', 'y'])
Authorize.allPermissions(['x', 'y'])
Authorize.anyRole(['admin', 'qa_lead'])
Authorize.allRoles(['admin', 'qa_lead'])
// Composition
Authorize.allOf([Authorize.role('qa_lead'), Authorize.permission('x')])
Authorize.anyOf([...])
Authorize.not(Authorize.self())
// Singletons
Authorize.allow // implicit when no annotation is set
Authorize.deny // explicit short-circuit (e.g. feature flag off)Permission strings support a dot-segment wildcard convention — lms.* (or just lms) matches lms.areas.view, lms.batch.modify, etc. See AuthorizationContext.matchesPermission for the full rules.
AuthorizeFallback variants
AuthorizeFallback.hide() // remove from tree
AuthorizeFallback.disable(title: '...', message: '...') // render but block
AuthorizeFallback.placeholder(title: '...', message: '...') // LockedSurface card
AuthorizeFallback.redact(mask: '••••') // for field reads
AuthorizeFallback.widget(MyCustomFallback) // registered widgetAuthorizeDefaults (passed to the runtime, not the annotation) sets the last-resort fallback for each surface — route, menu, tab, fieldRead, fieldWrite, action, widget, section. Resolution chain:
- The rule's own
AuthorizeRule.fallback - The bundle's
AuthorizeEntity.fallback - The matching
AuthorizeDefaultsslot AuthorizeFallback.hide()as the final default
EntityOwnership for Authorize.self()
Authorize.self() requires the entity to declare how to extract the owner user ID from a row. EntityOwnership is a sealed class with three named constructors:
// Single-relation by function tear-off
String? _submitterOf(Batch b) => b.submitterId;
ownership: EntityOwnership.via(_submitterOf)
// Single-relation by JSON field name (uses subject.toJson()[fieldName])
ownership: EntityOwnership.field('submitter_id')
// Multi-relation
ownership: EntityOwnership.named(
{'creator': _creatorOf, 'assignee': _assigneeOf},
defaultRelation: 'creator',
)Authorized — wrapping individual items
Inside actions, details, and analytics, you can pass either a Type reference or an Authorized(Type, authorize: ...) wrapper.
inline: [
EditAction,
Authorized(DeactivateAction,
authorize: Authorize.permission('lms.areas.deactivate'),
),
Authorized(GenerateQrPdfAction,
authorize: Authorize.anyRole(['operator', 'qa_lead']),
),
],Today the generator flattens authorize to permission strings at the legacy action surface; composition expressions that cannot be flattened are reported as a generation error. Route-level AuthorizeEntity already supports the full composition surface.
Layouts
@Entity.layouts accepts either an EntityUILayouts const instance or a top-level function tear-off returning EntityLayouts<T>.
layouts: EntityUILayouts(
table: TableLayout(
columns: [
TableColumn('name', widthFactor: 0.25, minWidth: 180),
TableColumn('areaCategoryId', widthFactor: 0.20, minWidth: 150),
TableColumn('capacity', widthFactor: 0.10),
],
rowHeight: 64.0,
pinnedColumnCount: 1,
),
details: EntityDetailLayouts([
AreaDetailLayout,
AreaEquipmentLayout,
Authorized(AreaPolicyLayout,
authorize: Authorize.permission('lms.areas.update'),
),
]),
analytics: [AreaUsageDistributionLayout, AreaTopUsedLayout],
)| Slot | Type | Notes |
|---|---|---|
table | TableLayout? | Declarative column config. Skipped when list is set. |
list | List<Object>? (Types or Authorized wrappers) | Custom list layouts that replace the generated table layout. |
details | EntityDetailLayouts? | Detail tabs. Capability tabs (version, audit, draft, relationships) auto-append unless you use EntityDetailLayouts.explicit([...]). |
analytics | List<Object>? | Analytics/aggregate layouts (Types or Authorized wrappers). |
TableColumn(String field, {double? widthFactor, double? minWidth, ColumnViewport visibleFrom = ColumnViewport.always, ColumnAppearance appearance = ColumnAppearance.auto}) references a Dart field name (camelCase) that maps to the matching @Field-annotated property. widthFactor is proportional when between 0 and 1, fixed pixels when >= 1, and omitted when the table should rely on minWidth. Use visibleFrom for responsive column visibility and ColumnAppearance.entityName for the primary identity cell.
Actions
@Entity.actions accepts either an EntityUIActions const instance or a top-level function tear-off returning EntityActions<T>.
actions: EntityUIActions(
inline: [
Authorized(EditAction,
authorize: Authorize.permission('lms.areas.update'),
),
...EntityActionPresets.draftActions,
...EntityActionPresets.statusActions,
],
collection: [GenerateQrPdfAction],
)EntityUIActions has two constructors:
| Constructor | Behaviour |
|---|---|
EntityUIActions({...}) | Additive mode (isExplicit: false): your custom actions are added alongside capability-derived defaults (VersionableActions.defaults<T>()). |
EntityUIActions.explicit({...}) | Explicit mode (isExplicit: true): only the listed actions are used; no capability auto-derivation. |
| Slot | Default |
|---|---|
inline | EntityActionPresets.versioned in additive mode; [] in explicit. |
header | StandardEntityActions.header<T>() (typically the create button). |
collection | None. |
menu | None. |
Action marker types and presets
Pass abstract action marker types as items — the generator maps them to the correct runtime factory. Compose presets with the spread operator:
inline: [...EntityActionPresets.versioned]
inline: [MyCustomAction, ...EntityActionPresets.editActions]
inline: [...EntityActionPresets.editActions, ...EntityActionPresets.statusActions]
inline: const []Available markers: EditAction, SubmitDraftAction, ResubmitDraftAction, CancelDraftAction, RestartDraftAction, DeactivateAction, ActivateAction, DeleteAction.
Available presets on EntityActionPresets: versioned, standard, editActions, draftActions, statusActions.
Editor and read-only
| Configuration | Behaviour |
|---|---|
@Entity(...) (no editor, readOnly: false) | Generator emits a StandardEntityEditor<T> wired to the auto-emitted $<cls>Form. |
@Entity(..., editor: AreaEditor) | Custom editor class — generator emits AreaEditor(). |
@Entity(..., editor: _createAreaEditor) | Function tear-off returning EntityEditor<T> — generator emits EntityEditor.function(_createAreaEditor). |
@Entity(..., readOnly: true) | No editor at all; the editor slot on EntityConfiguration<T> is omitted. |
Relationships
Non-FK relationships go on @Entity.relationships. FK picker fields are ordinary @Field declarations with type: ReferenceFieldType(...); read-side projections use FieldCompanion.lookup, FieldCompanion.sidecar, or FieldCompanion.aggregate.
relationships: [
Relationship.manyToMany(Equipment),
Relationship.manyToMany(Area, name: 'subareas', symmetric: true),
Relationship.oneToMany(Activity),
]| Constructor | Meaning |
|---|---|
Relationship.manyToMany(Type target, {String? name, bool symmetric}) | Many-to-many via a junction table. Pass symmetric: true for self-referential links like Area <-> Subarea. |
Relationship.oneToMany(Type target, {String? name}) | One-to-many where the child carries the FK back to this entity. |
The optional name drives the API path (/{id}/{name}), request body key ({singular(name)}_ids), tab title, and (for oneToMany) the child FK column name. Defaults to the pluralized snake_case form of the target type.
Tags
tags: [
EntityTag.group('master-data', label: 'Master Data'),
EntityTag.group('operational'),
EntityTag.feature('pharma-regulated'),
],| Kind | Use case |
|---|---|
group | Logical grouping queryable at runtime (e.g. all "master data" entities). |
feature | Cross-cutting feature participation (e.g. "pharma-regulated" entities for audit hooks). |
Tags do not affect generated code — they're attached to EntityMetadata.tags for runtime queries by feature code that wants to discover all entities matching a tag.
Localization
EntityI18n connects an entity's display strings to a generated translations file (e.g. from slang). Instead of inlining locale maps, you point the generator at a translation key prefix plus the import URI of the generated translations file.
i18n: EntityI18n(
keyPrefix: 'lms.entities.area',
source: 'package:feature_lms/i18n/lms_translations.g.dart',
alias: 'lms_t',
),Keys follow a fixed convention so the generator can resolve everything without further configuration:
| Key | Resolves to |
|---|---|
{keyPrefix}.singular | Entity name |
{keyPrefix}.plural | Entity pluralName |
{keyPrefix}.description | Entity description |
{keyPrefix}.fields.{fieldName} | @Field label |
{keyPrefix}.tabs.{tabId} | Layout tab title |
{keyPrefix}.layouts.table | Table layout title |
Custom API class
@Entity(..., apiClass: AreaApi)When set, the generator references AreaApi() in the entity config instead of constructing the generated $AreaApi directly. Your AreaApi typically extends $AreaApi to add domain-specific methods.
@Field — field-level annotation
@Field marks a Dart member for inclusion in:
- The generated
<Cls>Fieldsregistry as a typedFieldDefinition<T>. - The default
$<cls>Form(form node order follows declaration order). - The default
$<cls>TableLayout(when no explicitTableLayoutis set).
@Field(
label: 'Code',
filterable: true,
sortable: true,
unique: true,
derivedFrom: 'name',
immutableAfterCreate: true,
)
final String code;
@Field(label: 'Description', type: TextFieldType(lines: 3))
final String? description;
@Field(
label: 'Capacity',
filterable: true,
sortable: true,
type: NumberFieldType(min: 0, integerOnly: true),
)
final int? capacity;
@Field(
label: 'Parent Area',
type: ReferenceFieldType(
entityType: 'areas',
subtitleFields: ['code'],
searchFields: ['name', 'code'],
showId: false,
excludeSelf: true,
),
sortField: 'parent_area_id.name',
)
final String? parentAreaId;Constructors
| Constructor | Use when |
|---|---|
Field({required label, ...}) | Database column name is auto-derived from the Dart property name. |
Field.named(String name, {...}) | Database column name does not match the Dart property name. |
Parameters
| Parameter | Type | Default | Effect |
|---|---|---|---|
label | String | — | Display label in tables, filters, forms, and detail cards. |
type | FieldType? | null | Form editor type with configuration. Inferred from Dart type when null. |
filterable | bool | false | Appears as a filter option in list views. |
sortable | bool | false | Sortable in list views. |
searchable | bool | false | Included in server-side text-search queries. |
sortField | String? | null | Override sort field path (e.g. 'area_category_id.name'). |
filterField | String? | null | Override filter field path. |
companion | FieldCompanion? | null | Read-side projection metadata for lookup embeds, sidecar embeds, or aggregates. |
unique | bool | false | Drives client async validator + form .unique(...) chain + server uniqueness checks. The DB should also carry a matching UNIQUE constraint; the server translates unique_violation to a 409. |
derivedFrom | String? | null | Auto-derive this field's value from another field (typically 'name') until the user edits this field directly. Pair with transform. |
transform | Object? | null | Const FieldTransform instance (e.g. SnakeCaseTransform()) controlling how derivedFrom source values are transformed. Defaults to SnakeCaseTransform. |
immutableAfterCreate | bool | false | Field is editable at create time but locked on subsequent edits. Useful for codes, serial numbers. Replaces the removed CodeFieldType.immutableAfterCreate. |
FieldType — sealed hierarchy
FieldType is a sealed base — the generator exhaustively matches every subtype, so adding a new field type in the annotations package requires a matching branch in form_emitter.dart, caught at compile time. All types inherit required and placeholder from FieldType.
| Type | Purpose | Key params |
|---|---|---|
TextFieldType | Single/multi-line text | lines, minLength, maxLength, pattern, patternError |
NumberFieldType | Numeric input | min, max, integerOnly, decimalPlaces |
BooleanFieldType | Toggle | trueLabel (default 'Yes'), falseLabel (default 'No') |
DateTimeFieldType | Date/time picker | dateOnly |
SelectFieldType | Dropdown (single or multi-select) | options (Map<String, String>?), multi (defaults false; backed by TEXT[] for List<String> fields) |
ReferenceFieldType | FK entity picker | entityType (required), allowCreate, titleField, subtitleFields, searchFields, showId, excludeSelf |
EmailFieldType | Email with built-in validation | — |
PhoneFieldType | Phone number input | — |
UrlFieldType | URL with built-in ^https?:// regex validation | — |
Code fields
The previously-separate CodeFieldType was folded into @Field flags. Use @Field(unique: true, derivedFrom: 'name', immutableAfterCreate: true) to get the same behaviour on any text field.
Field-level authorization
Use AuthorizeField for read/write gating with asymmetric defaults — omit the lenient axis, specify the strict one.
// Visible to all, editable only by finance — the 90% case.
AuthorizeField(write: Authorize.role('finance'))
// Visible and editable only by HR.
AuthorizeField(read: Authorize.role('hr'))
// Visible to HR + managers, editable only by HR.
AuthorizeField(
read: Authorize.anyRole(['hr', 'manager']),
write: Authorize.role('hr'),
)Defaulting rules:
readomitted ->Authorize.allow(visible to all)writeomitted ->read(write-as-tight-as-read)
@ServerEntity — the server annotation
@ServerEntity marks a server-side model class for code generation. It emits a .server.dart part file with a $<Cls>Base superclass, an EntityCrudConfig<T>, and an EntityCrudDescriptor<T> that server feature descriptors can list directly.
import 'package:dart_server_core/dart_server_core.dart';
import 'package:json_annotation/json_annotation.dart';
import 'package:vyuh_entity_annotations/vyuh_entity_annotations.dart';
part 'area.g.dart';
part 'area.server.dart';
@ServerEntity(
schema: 'lms',
identifier: 'areas',
name: 'Area',
pluralName: 'Areas',
tenant: TenantConfig.siteScoped(),
defaultSortField: 'code',
capabilities: {EntityCapability.draftable()},
hooks: ServerHooks(
validateCreate: validateAreaCreate,
validateUpdate: validateAreaUpdate,
beforeDelete: checkAreasInUse,
),
additionalSortableFields: ['name'],
additionalSearchableFields: ['name'],
openApiSchema: $AreaOpenApiSchema,
)
@JsonSerializable(fieldRename: FieldRename.snake, explicitToJson: true)
class Area extends $AreaBase {
// ... @Field-annotated members
}Identity
| Parameter | Type | Default | Notes |
|---|---|---|---|
schema | String | required | Database schema (e.g. 'lms'). |
identifier | String | required | Drives the table name and base API path. |
name | String | required | Singular display name (logs, errors, OpenAPI). |
pluralName | String | required | Plural display name. |
table | String? | identifier | Explicit table name override. |
basePath | String? | '/{identifier}' | API base path override. |
defaultSortField | String? | table-name fallback | Default sort field for list queries. |
Scoping
tenant accepts a const TenantConfig from dart_server_core:
tenant: TenantConfig.siteScoped() // company + site filtering
tenant: TenantConfig.companyScoped() // company-only filtering
tenant: null // global, no isolationCapabilities (server flavour)
Reuses EntityCapability from the client annotations. Each capability maps to a server-side mixin on $<Cls>Base:
| Capability | Server mixin |
|---|---|
EntityCapability.auditable() | Auditable (createdAt, updatedAt, createdBy, updatedBy) |
EntityCapability.versioned() | Versionable (versionNumber, isActive). Implies auditable. |
EntityCapability.draftable() | Draftable (draftMetadata). Implies versioned. |
EntityCapability.cached(...) | Client-only — ignored by the server generator. |
Lifecycle hooks
ServerHooks provides two levels of customization per CRUD operation. All hook parameters accept top-level function tear-offs.
hooks: ServerHooks(
validateCreate: validateAreaCreate,
validateUpdate: validateAreaUpdate,
afterCreate: _notifyAreaCreated,
)
// Fully custom create handler, standard update/delete with hooks
hooks: ServerHooks(
onCreate: createUserWithAuthHandler,
validateDelete: _ensureNoActiveChildren,
)| Hook family | Slots | Behaviour |
|---|---|---|
| Handler replacement | onCreate, onUpdate, onDelete | Replaces the entire CRUD handler with a custom Future<Response> Function(Request). Disables matching hooks. |
| Validation | validateCreate, validateUpdate, validateDelete | Reject invalid input by throwing. |
| Pre-flight (transform) | beforeCreate, beforeUpdate, beforeDelete | Transform/enrich data before persistence. |
| Post-flight (effects) | afterCreate, afterUpdate, afterDelete | Side-effects after successful operation (webhooks, events, notifications). |
Routing
| Parameter | Type | Notes |
|---|---|---|
customRoutes | Object? (function) | Tear-off void Function(TrackedRouter router, String basePath). Registered before standard CRUD so literal segments win over :id. |
relationships | List<ServerRelationship> | See below. |
errorHandler | Object? (function) | Tear-off Future<Response> Function(Object error, {StackTrace? stackTrace}). Wired into EntityRouteOptions.errorHandler. |
protectedPaths | List<String>? | null (default) protects all paths under basePath; [] is fully public; non-empty list explicitly protects only those paths. |
openApiSchema | Object? (const ref) | Const ref to an OpenApiEntitySchema (often $<Cls>OpenApiSchema from openapi_schema_generator). Forwarded to the generated config. |
additionalSortableFields | List<String> | DB columns sortable but not declared via @Field(sortable: true) — e.g. 'created_at', 'updated_at'. |
additionalSearchableFields | List<String> | DB columns searchable but not declared via @Field(searchable: true). |
uniqueName | bool | Defaults true — auto-includes 'name' in EntityConfig.uniqueFields. Set false for instance entities where duplicate names are allowed. |
ServerRelationship — server-side relationship routes
Unlike client-side Relationship, this carries concrete table and column names so the generator can register junction or one-to-many sub-routes.
relationships: [
ServerRelationship.manyToMany(
name: 'user_groups',
junctionTable: 'area_user_groups',
entityIdColumn: 'area_id',
relatedIdColumn: 'user_group_id',
relatedTable: 'user_groups',
multipleIdsKey: 'user_group_ids',
),
ServerRelationship.oneToMany(
name: 'children',
childTable: 'areas',
foreignKeyField: 'parent_area_id',
),
]Both constructors register GET/POST/DELETE on /{basePath}/{id}/{name}.
What's next?
- Entity Generator — turn the annotations into
.entity.dartand.server.dartpart files. - Defining Entities — end-to-end walkthrough from model to feature registration.
- Entity Configuration API — reference for the generated
EntityConfiguration<T>shape. - Configuration concepts — how the generated config maps onto runtime behaviour.