Skip to content

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.tech

Don'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.

yaml
dependencies:
  vyuh_entity_annotations:
    hosted: https://pub.vyuh.tech
    version: ^3.3.0

In practice you almost always pair this with vyuh_entity_generator as a dev dependency — see the Generator guide.

Cheat sheet — annotation to generated artifact

AnnotationWhere it goesWhat the generator emits
@EntityClient 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.namedMember of an @Entity classOne typed FieldDefinition<T> static on <Cls>Fields, one column on $<cls>TableLayout, one form node on $<cls>Form, contributes to detail layout
@ServerEntityServer model class extending $<Cls>Base$<Cls>Base (server flavour), $<cls>ServerCrudConfig (EntityCrudConfig<T>), $<cls>ServerDescriptor (EntityCrudDescriptor<T>)
EntityCapability.*@Entity / @ServerEntity capabilitiesMixin set on the base class; capability-specific fields, API mixins, detail tabs, lifecycle hooks
EntityVisibility.*@Entity.visibilityAnnotation-side visibility vocabulary; generator maps it to runtime UIVisibility entries on EntityMetadata.visibility
EntityTag.group/.feature@Entity.tagsTag list on EntityMetadata.tags; queryable at runtime
Relationship.manyToMany/oneToMany@Entity.relationshipsRelationshipApiMixin<T> + getLinked*/link*/unlink* methods on the API; one $<Cls><Rel>RelationshipLayout detail tab per relationship
ServerRelationship.manyToMany/oneToMany@ServerEntity.relationshipsRelationship metadata folded into the generated server CRUD descriptor/config
EntityNavigation + AuthorizeEntity@Entity.navigationEntityRouting<T> with path, builder, and per-operation permissions/authorize rules
EntityUILayouts + TableLayout@Entity.layoutsEntityLayouts<T> with list, details, analytics; TableLayout drives $<cls>TableLayout columns
EntityUIActions + presets@Entity.actionsEntityActions<T> with inline/header/collection/menu; capability-derived defaults when not explicit
Authorized(Type, authorize:)Inside actions / details / analyticsType entry plus authorize expression flattened to permission strings at the legacy surface
Authorize.* / AuthorizeRuleAuthorizeEntity, AuthorizeField, etc.Const expression evaluated against AuthorizationContext at runtime
EntityOwnership.*@Entity.ownership (reserved)Owner-extractor wired into Authorize.self() evaluation
EntityI18n@Entity.i18nnameResolver, pluralNameResolver, descriptionResolver, and per-field labelResolver functions reading from your translations source
ServerHooks@ServerEntity.hooksHandler replacements (onCreate/Update/Delete) and lifecycle hooks (validate*, before*, after*) wired into the generated EntityCrudConfig
TenantConfig.*@ServerEntity.tenantCaptured 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.

dart
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

ParameterRequiredTypePurpose
schemayesStringDatabase schema (e.g. 'lms', 'sso'). Drives RPC calls and route paths.
identifieryesStringPrimary identifier for API paths and lookups (e.g. 'areas').
nameyesStringSingular display name (English fallback).
pluralNameyesStringPlural display name (English fallback).
descriptionyesStringShort description for menus and tooltips (English fallback).
alternateIdentifiersList<String>Alternate identifiers for lookups (e.g. ['area'] for 'areas'). Default const [].
iconObject? (IconData)Const IconData for menus, tabs, and headers.
categoryObject?Const EntityCategory instance or a top-level function tear-off returning one.
menuCategoryObject?Const MenuCategory instance or a top-level function tear-off returning one.
priorityintDisplay priority in menus and listings (lower = higher priority). Default 0.
defaultSortFieldString?Default sort field for list queries.
fieldAliasesMap<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().

dart
// 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 {},
ConstructorEffect
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.

CapabilityEffect
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.
dart
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.

@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.

dart
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.

dart
// 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

dart
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 widget

AuthorizeDefaults (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:

  1. The rule's own AuthorizeRule.fallback
  2. The bundle's AuthorizeEntity.fallback
  3. The matching AuthorizeDefaults slot
  4. 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:

dart
// 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.

dart
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>.

dart
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],
)
SlotTypeNotes
tableTableLayout?Declarative column config. Skipped when list is set.
listList<Object>? (Types or Authorized wrappers)Custom list layouts that replace the generated table layout.
detailsEntityDetailLayouts?Detail tabs. Capability tabs (version, audit, draft, relationships) auto-append unless you use EntityDetailLayouts.explicit([...]).
analyticsList<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>.

dart
actions: EntityUIActions(
  inline: [
    Authorized(EditAction,
      authorize: Authorize.permission('lms.areas.update'),
    ),
    ...EntityActionPresets.draftActions,
    ...EntityActionPresets.statusActions,
  ],
  collection: [GenerateQrPdfAction],
)

EntityUIActions has two constructors:

ConstructorBehaviour
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.
SlotDefault
inlineEntityActionPresets.versioned in additive mode; [] in explicit.
headerStandardEntityActions.header<T>() (typically the create button).
collectionNone.
menuNone.

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:

dart
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

ConfigurationBehaviour
@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.

dart
relationships: [
  Relationship.manyToMany(Equipment),
  Relationship.manyToMany(Area, name: 'subareas', symmetric: true),
  Relationship.oneToMany(Activity),
]
ConstructorMeaning
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

dart
tags: [
  EntityTag.group('master-data', label: 'Master Data'),
  EntityTag.group('operational'),
  EntityTag.feature('pharma-regulated'),
],
KindUse case
groupLogical grouping queryable at runtime (e.g. all "master data" entities).
featureCross-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.

dart
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:

KeyResolves to
{keyPrefix}.singularEntity name
{keyPrefix}.pluralEntity pluralName
{keyPrefix}.descriptionEntity description
{keyPrefix}.fields.{fieldName}@Field label
{keyPrefix}.tabs.{tabId}Layout tab title
{keyPrefix}.layouts.tableTable layout title

Custom API class

dart
@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:

  1. The generated <Cls>Fields registry as a typed FieldDefinition<T>.
  2. The default $<cls>Form (form node order follows declaration order).
  3. The default $<cls>TableLayout (when no explicit TableLayout is set).
dart
@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

ConstructorUse 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

ParameterTypeDefaultEffect
labelStringDisplay label in tables, filters, forms, and detail cards.
typeFieldType?nullForm editor type with configuration. Inferred from Dart type when null.
filterableboolfalseAppears as a filter option in list views.
sortableboolfalseSortable in list views.
searchableboolfalseIncluded in server-side text-search queries.
sortFieldString?nullOverride sort field path (e.g. 'area_category_id.name').
filterFieldString?nullOverride filter field path.
companionFieldCompanion?nullRead-side projection metadata for lookup embeds, sidecar embeds, or aggregates.
uniqueboolfalseDrives 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.
derivedFromString?nullAuto-derive this field's value from another field (typically 'name') until the user edits this field directly. Pair with transform.
transformObject?nullConst FieldTransform instance (e.g. SnakeCaseTransform()) controlling how derivedFrom source values are transformed. Defaults to SnakeCaseTransform.
immutableAfterCreateboolfalseField 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.

TypePurposeKey params
TextFieldTypeSingle/multi-line textlines, minLength, maxLength, pattern, patternError
NumberFieldTypeNumeric inputmin, max, integerOnly, decimalPlaces
BooleanFieldTypeToggletrueLabel (default 'Yes'), falseLabel (default 'No')
DateTimeFieldTypeDate/time pickerdateOnly
SelectFieldTypeDropdown (single or multi-select)options (Map<String, String>?), multi (defaults false; backed by TEXT[] for List<String> fields)
ReferenceFieldTypeFK entity pickerentityType (required), allowCreate, titleField, subtitleFields, searchFields, showId, excludeSelf
EmailFieldTypeEmail with built-in validation
PhoneFieldTypePhone number input
UrlFieldTypeURL 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.

dart
// 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:

  • read omitted -> Authorize.allow (visible to all)
  • write omitted -> 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.

dart
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

ParameterTypeDefaultNotes
schemaStringrequiredDatabase schema (e.g. 'lms').
identifierStringrequiredDrives the table name and base API path.
nameStringrequiredSingular display name (logs, errors, OpenAPI).
pluralNameStringrequiredPlural display name.
tableString?identifierExplicit table name override.
basePathString?'/{identifier}'API base path override.
defaultSortFieldString?table-name fallbackDefault sort field for list queries.

Scoping

tenant accepts a const TenantConfig from dart_server_core:

dart
tenant: TenantConfig.siteScoped()      // company + site filtering
tenant: TenantConfig.companyScoped()   // company-only filtering
tenant: null                            // global, no isolation

Capabilities (server flavour)

Reuses EntityCapability from the client annotations. Each capability maps to a server-side mixin on $<Cls>Base:

CapabilityServer 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.

dart
hooks: ServerHooks(
  validateCreate: validateAreaCreate,
  validateUpdate: validateAreaUpdate,
  afterCreate: _notifyAreaCreated,
)

// Fully custom create handler, standard update/delete with hooks
hooks: ServerHooks(
  onCreate: createUserWithAuthHandler,
  validateDelete: _ensureNoActiveChildren,
)
Hook familySlotsBehaviour
Handler replacementonCreate, onUpdate, onDeleteReplaces the entire CRUD handler with a custom Future<Response> Function(Request). Disables matching hooks.
ValidationvalidateCreate, validateUpdate, validateDeleteReject invalid input by throwing.
Pre-flight (transform)beforeCreate, beforeUpdate, beforeDeleteTransform/enrich data before persistence.
Post-flight (effects)afterCreate, afterUpdate, afterDeleteSide-effects after successful operation (webhooks, events, notifications).

Routing

ParameterTypeNotes
customRoutesObject? (function)Tear-off void Function(TrackedRouter router, String basePath). Registered before standard CRUD so literal segments win over :id.
relationshipsList<ServerRelationship>See below.
errorHandlerObject? (function)Tear-off Future<Response> Function(Object error, {StackTrace? stackTrace}). Wired into EntityRouteOptions.errorHandler.
protectedPathsList<String>?null (default) protects all paths under basePath; [] is fully public; non-empty list explicitly protects only those paths.
openApiSchemaObject? (const ref)Const ref to an OpenApiEntitySchema (often $<Cls>OpenApiSchema from openapi_schema_generator). Forwarded to the generated config.
additionalSortableFieldsList<String>DB columns sortable but not declared via @Field(sortable: true) — e.g. 'created_at', 'updated_at'.
additionalSearchableFieldsList<String>DB columns searchable but not declared via @Field(searchable: true).
uniqueNameboolDefaults 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.

dart
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?