Skip to content

Entity Generator

vyuh_entity_generator reads @Entity, @Field, and @ServerEntity annotations from vyuh_entity_annotations and emits two kinds of part files:

  • *.entity.dart — client artifacts: base class, fields registry, API, layouts, form, metadata, routing, workspace config, and the assembled EntityConfiguration<T>.
  • *.server.dart — server artifacts: base class, EntityCrudConfig<T>, and an EntityCrudDescriptor<T> ready for a server feature descriptor.

Two build_runner builders, twelve client emitters, and two server emitter groups. The server descriptor emitter writes both the CRUD config and the descriptor. Domain logic stays in your code; the generator only produces the generic infrastructure that would otherwise be repeated across every entity.

Installation

Authenticate with pub.vyuh.tech first

`vyuh_entity_generator` 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
  vyuh_entity_system:
    hosted: https://pub.vyuh.tech
    version: ^5.1.0

dev_dependencies:
  vyuh_entity_generator:
    hosted: https://pub.vyuh.tech
    version: ^3.3.0
  build_runner: ^2.8.0
  json_serializable: ^6.7.1

The generator transitively pulls in source_gen, build, analyzer, code_builder, and dart_style.

One package, two roles

Add vyuh_entity_annotations everywhere you write models (client packages, server packages, shared packages). Add vyuh_entity_generator only to the packages where the generator should run — typically the *_entities packages on each side.

Builders

The generator ships two builders, both wired through build.yaml:

Builder factoryTriggered byOutput extensionRuns before
entityBuilder (package:vyuh_entity_generator/builder.dart)@Entity.entity.dartjson_serializable
serverEntityBuilder (package:vyuh_entity_generator/server_builder.dart)@ServerEntity.server.dartjson_serializable

Both run before json_serializable because the emitted code references fromJson/toJson helpers from the matching .g.dart file.

auto_apply: dependents means the builders activate automatically in any package that depends on vyuh_entity_generator — no extra build.yaml needed in your own package for the common case.

Running the generator

bash
# One-shot
dart run build_runner build --delete-conflicting-outputs

# Watch mode
dart run build_runner watch --delete-conflicting-outputs

Always regenerate after annotation edits

Any change to @Entity, @Field, @ServerEntity, or @JsonSerializable requires re-running build_runner before considering the edit done. The generator does not run on save automatically.

analysis_options.yaml excludes **/*.entity.dart and **/*.server.dart from analysis, which means the analyzer cannot catch generator-emitted type errors. The canonical post-codegen verification path is hot-restart against the running app, or temporarily un-exclude the generated files in analysis_options.yaml.

Client emitters (.entity.dart)

EntityGenerator invokes twelve emitters in order and concatenates their output into a single *.entity.dart part file.

EmitterOutputNotes
base_class_emitter -> emitBaseClass$<Cls>Base superclassMixes in Auditable, Versionable, Draftable, and HasDescription based on capabilities and field declarations.
fields_emitter -> emitFields<Cls>Fields static-only classOne FieldDefinition<T> per @Field-annotated member, plus static const all = [...].
api_emitter -> emitApi$<Cls>Api class extending HttpEntityApi<T>Mixes in VersioningApiMixin<T>, DraftApiMixin<T>, RelationshipApiMixin<T> per capabilities; emits cachingPolicy override.
table_emitter -> emitTable$<cls>TableLayout() returning EntityTableConfig<T>Returns null when EntityUILayouts.list is supplied (custom list layouts replace the generated table).
kanban_emitter -> emitKanban$<cls>KanbanLayout() returning EntityKanbanConfig<T>Emitted when EntityUILayouts.kanban is supplied; columns come from enum options or explicit groups.
detail_emitter -> emitDetail$<Cls>DetailLayout class extending EntityLayout<T>Skipped when EntityUILayouts.details already provides custom layouts.
relationship_tab_emitter -> emitRelationshipTabsOne $<Cls><Suffix>RelationshipLayout per relationshipWraps EntityRelationshipLayout<T, R>. Emits both manyToMany and oneToMany tabs from @Entity.relationships.
metadata_emitter -> emitMetadata$<cls>Metadata() returning EntityMetadataIdentity, icon, category/menuCategory, priority, visibility set, tags, alternate identifiers, default sort field, i18n.
routing_emitter -> emitRouting$<cls>Routing() returning EntityRouting<T>path from EntityNavigation.prefix (default '/{schema}/{identifier}'), builder: EntityWorkspaceRouteBuilder<T>, permissions.
form_emitter -> emitForm$<cls>Form(...) returning vf.FormEmitted only when the entity has an editor. Generated form covers all @Field declarations (see Form generation).
config_emitter -> emitConfig<cls>Config (final field of type EntityConfiguration<T>)Wires metadata, api, fields, routing, layouts, editor, actions; resolves capability-derived defaults and Authorized wrappers.
workspace_emitter -> emitWorkspace$<cls>WorkspaceConfig() returning EntityWorkspaceConfig<T>Wires the table layout into EntityBodyPart<T>. Authors copyWith to override header/detail parts.

Example output

For a class class Area extends $AreaBase, the generator emits area.entity.dart along these lines:

dart
part of 'area.dart';

// ─── Base class with capability mixins ───────────────────────────
abstract class $AreaBase extends EntityBase
    with Auditable, Versionable, Draftable, HasDescription { ... }

// ─── Field registry ──────────────────────────────────────────────
class AreaFields {
  static const code     = FieldDefinition<String>(...);
  static const name     = FieldDefinition<String>(...);
  static const capacity = FieldDefinition<int?>(...);
  // ... one per @Field-annotated member
  static const all = <FieldDefinition>[code, name, capacity, ...];
}

// ─── API base class ──────────────────────────────────────────────
class $AreaApi extends HttpEntityApi<Area>
    with VersioningApiMixin<Area>, DraftApiMixin<Area>,
         RelationshipApiMixin<Area> {
  $AreaApi() : super(
    entityType: 'areas',
    schemaType: 'lms.areas',
    pathBuilder: EndpointBuilder(prefix: 'areas'),
    fromJson: Area.fromJson,
  );
}

// ─── Default table layout ────────────────────────────────────────
EntityTableConfig<Area> $areaTableLayout() => EntityTableConfig<Area>(
  title: 'Table', identifier: 'table', icon: FluentIcons.table_24_regular,
  rowHeight: 64.0,
  columns: [...],
);

// ─── Default detail layout ───────────────────────────────────────
class $AreaDetailLayout extends EntityLayout<Area> { ... }

// ─── Relationship tabs (one per @Entity.relationships entry) ────
class $AreaEquipmentsRelationshipLayout extends EntityLayout<Area> { ... }

// ─── Metadata, routing, form, workspace ──────────────────────────
EntityMetadata $areaMetadata() => EntityMetadata(...);
EntityRouting<Area> $areaRouting() => EntityRouting<Area>(...);
vf.Form $areaForm({String? entityId, bool isDraft = false, ...}) { ... }
EntityWorkspaceConfig<Area> $areaWorkspaceConfig() { ... }

// ─── The assembled config ────────────────────────────────────────
final areaConfig = EntityConfiguration<Area>(
  metadata: $areaMetadata(),
  api: $AreaApi(),
  fields: AreaFields.all,
  routing: $areaRouting(),
  layouts: EntityLayouts(list: [$areaTableLayout()], ...),
  editor: StandardEntityEditor<Area>(...),
  actions: VersionableActions.defaults<Area>(),
);

Generated identifier conventions

IdentifierPatternExample
Base class$<Cls>Base$AreaBase
Fields registry<Cls>FieldsAreaFields
API class$<Cls>Api$AreaApi
Effective API classapiClass if set, otherwise $<Cls>ApiAreaApi (when apiClass: AreaApi)
Table layout function$<cls>TableLayout()$areaTableLayout()
Default detail layout$<Cls>DetailLayout$AreaDetailLayout
Relationship tab class$<Cls><Suffix>RelationshipLayout$AreaEquipmentsRelationshipLayout
Metadata function$<cls>Metadata()$areaMetadata()
Routing function$<cls>Routing()$areaRouting()
Form function$<cls>Form({...})$areaForm({...})
Workspace config function$<cls>WorkspaceConfig()$areaWorkspaceConfig()
Final config field<cls>ConfigareaConfig

Form generation

form_emitter produces a real working form from @Field declarations. The emitter resolves a category for each field — FieldType annotation name > Dart type > structural keys (reference, enum) — and dispatches to the form-builder method registry:

CategoryForm builder methodPost-chain extras
TextFieldType, String, EmailFieldType, UrlFieldType.text(...)EmailFieldType -> .email(); UrlFieldType -> .pattern(...) matching ^https?://.
NumberFieldType, int, double, num.number(...)min, max, integerOnly.
BooleanFieldType, bool.boolean(...)
DateTimeFieldType, DateTime.dateTime(...)
SelectFieldType, enum.select(...).options(...) from options map; .multiple() when multi: true.
PhoneFieldType.phone(...)
reference.reference(...).provider(EntityReferenceProvider(...)), .allowCreate(), excludeEntityId: entityId for self.

Common chainers (placeholder, minLength, maxLength, pattern) are applied to any field type from FieldType properties. @Field(unique: true) emits .unique(schema:, table:, fieldName:, excludeId:), and @Field(derivedFrom: 'name') emits .identifier().derivedFrom({'name'}, transformer: SnakeCaseTransform().apply, initiallyDirty: entityId != null). @Field(immutableAfterCreate: true) emits .enabled(entityId == null || isDraft).

The generated form signature is:

dart
vf.Form $areaForm({
  String? entityId,
  bool isDraft = false,
  Map<String, dynamic>? initialValues,
})

Field definitions

fields_emitter resolves each @Field to a typed FieldDefinition<T>:

  • String -> TextFieldDef
  • int / double / num -> NumberFieldDef (with allowDecimal: false for int or when integerOnly: true)
  • bool -> BooleanFieldDef
  • DateTime -> DateTimeFieldDef
  • enum types -> EnumFieldDef
  • ReferenceFieldType(entityType: '...') -> ReferenceFieldDef.auto(targetEntityType: '...')

Validation, search/sort flags, and uniqueness are forwarded into the FieldDefinition constructor.

Read-side relationship projections are declared through @Field(companion: FieldCompanion.lookup/sidecar/aggregate(...)). Lookup companions can expose dotted field paths (for example equipment_category.name) because FieldDefinition.getValue can walk nested JSON maps.

Server emitters (.server.dart)

ServerEntityGenerator invokes the base-class emitter and the descriptor emitter:

EmitterOutput
server_base_class_emitter -> emitServerBaseClass$<Cls>Base superclass with capability mixins (Auditable, Versionable, Draftable) and their concrete stored fields. No Flutter dependencies.
server_descriptor_emitter -> emitServerDescriptorAndCrudConfig$<cls>ServerCrudConfig (EntityCrudConfig<T>) plus $<cls>ServerDescriptor (EntityCrudDescriptor<T>): schema/table, sortable/searchable/unique fields, hooks, embedded refs, companions, inverse embeds, virtual fields, projection table, OpenAPI component schema, scope, in-use guards, and field aliases.

Example output

dart
part of 'area.dart';

// ─── Base class ──────────────────────────────────────────────────
abstract class $AreaBase extends VersionedEntity
    with Auditable, Versionable, Draftable { ... }

// ─── Server CRUD config ──────────────────────────────────────────
final $areaServerCrudConfig = EntityCrudConfig<Area>(
  schema: 'lms',
  table: 'areas',
  searchableFields: ['code', 'description', 'room_number', 'name'],
  sortableFields: ['code', 'parent_area_id', 'capacity', /* ... */, 'name'],
  uniqueFields: ['name'],   // auto-added unless uniqueName: false
  defaultSortField: 'code',
  fromJson: Area.fromJson,
  toJson: (e) => e.toJson(),
  entityName: 'Area',
  entityNamePlural: 'Areas',
  validateCreate: validateAreaCreate,
  validateUpdate: validateAreaUpdate,
  beforeDelete: checkAreasInUse,
  openApiSchema: $AreaOpenApiSchema,
);

// ─── Server descriptor ───────────────────────────────────────────
final $areaServerDescriptor = EntityCrudDescriptor<Area>(
  name: 'Areas',
  basePath: '/areas',
  config: $areaServerCrudConfig,
  protectedPaths: null,  // null => all paths protected
);

Generated identifier conventions (server)

IdentifierPatternExample
Base class$<Cls>Base$AreaBase
Server CRUD config$<cls>ServerCrudConfig$areaServerCrudConfig
Server descriptor$<cls>ServerDescriptor$areaServerDescriptor

Add the descriptor to the server feature descriptor:

dart
FeatureDescriptor(
  name: 'lms',
  descriptors: [$areaServerDescriptor],
);

End-to-end flow

  1. Write the model. Annotate a class with @Entity (client) or @ServerEntity (server), declare part 'foo.entity.dart' (or 'foo.server.dart') and part 'foo.g.dart'. Annotate fields with @Field.
  2. Run the generator.
    bash
    dart run build_runner build --delete-conflicting-outputs
  3. Verify. Hot-restart the app (analyzer cannot see generated files — they're excluded from analysis_options.yaml), or temporarily un-exclude **/*.entity.dart to confirm zero analysis issues.
  4. Register. Add the generated config to your FeatureDescriptor (or add the server descriptor to your server feature):
    dart
    // Client
    final feature = FeatureDescriptor(
      name: 'feature_lms',
      extensions: [
        EntityExtensionDescriptor(entities: [areaConfig, equipmentConfig]),
      ],
    );
    
    // Server
    final lmsFeature = FeatureDescriptor(
      name: 'lms',
      descriptors: [$areaServerDescriptor, $equipmentServerDescriptor],
    );

Convention-based overrides (client)

The generator looks for hand-written hooks in the same library and uses them in place of the default. Lets you start with generator defaults and override just the piece you need without touching the annotation.

ConventionReplaces / overrides
class <Cls>Api extends $<Cls>ApiUse as apiClass: AreaApi to wire your subclass into the generated config.
Function tear-off in @Entity(navigation: _myFn)Replaces the generated EntityRouting<T>.
Function tear-off in @Entity(layouts: _myFn)Replaces the generated EntityLayouts<T>.
@Entity(editor: MyEditor) (Type) or function tear-offReplaces the auto-emitted StandardEntityEditor<T>.
@Entity(actions: _myFn)Replaces the generated EntityActions<T>.
@Entity(layouts: EntityUILayouts(list: [...]))Skips $<cls>TableLayout emission entirely.
@Entity(layouts: EntityUILayouts(details: [...]))Skips $<Cls>DetailLayout emission entirely.

"Function tear-offs everywhere"

Every configuration slot supports function tear-offs because annotations must be const-expressible. The generator emits the call site as EntityRouting.function(_areaNavigation) (or the equivalent factory) so the framework knows to invoke the function at startup.

build.yaml configuration

The generator ships with sensible defaults via its own build.yaml. In most projects you do not need to add anything to your own build.yaml. If you need to constrain the input set, use the standard build_runner mechanism:

yaml
# build.yaml at your package root
targets:
  $default:
    builders:
      vyuh_entity_generator|entity:
        generate_for:
          - lib/entities/**.dart
      vyuh_entity_generator|server_entity:
        generate_for:
          - lib/src/**.dart

The generator's bundled build.yaml declares runs_before: ["json_serializable"], so the emitted EntityApi.fromJson and EntityConfig.fromJson references resolve correctly.

Scaffolding with vyuh_ems_cli

vyuh_ems_cli (binary vyuh_ems) scaffolds new EMS monorepos and adds features and entities. It writes the annotated source (@Entity, @JsonSerializable, etc.) and the matching pubspec.yaml/feature.dart wiring, then optionally invokes build_runner so the .entity.dart files are produced before you open the project.

bash
vyuh_ems create my_app
vyuh_ems add feature inventory
vyuh_ems add entity Item --in inventory

The CLI itself does not emit any generated code — that's still the generator's job, invoked under the hood (or skipped with --no-build-runner if you want to inspect the scaffolded source first).

Things you should never do

  • Never edit *.entity.dart or *.server.dart files — they are re-emitted on every build_runner run and your changes will vanish. Modify the annotations or write a hand-rolled override that the generator picks up via convention.
  • Never depend on dart_style or code_builder from your application code — the generator uses them internally; consumers do not.
  • Never assume the analyzer caught a generator bug. The analysis_options.yaml exclusion of **/*.entity.dart / **/*.server.dart means analysis errors in generated code are silent. Hot-restart, or temporarily un-exclude, after every annotation change.

Troubleshooting

Unresolved identifier _$AreaFromJson Run build_runnerjson_serializable has not produced the .g.dart file yet. The generator's runs_before: ["json_serializable"] declaration ensures the generator emits first; running build_runner build produces both files in one pass.

@Entity annotation not recognized Ensure you import 'package:vyuh_entity_annotations/vyuh_entity_annotations.dart'; in the annotated file, and declare both part 'area.entity.dart'; and part 'area.g.dart'; near the top of the same file.

Generated form is missing fields The generated form covers every @Field-annotated member in declaration order. If a property is missing, check that it carries @Field(label: ...) — a bare final String foo; is invisible to the generator.

Changes to @Entity arguments do not appear after rebuild Some changes (new capabilities, new relationships) require deleting the output file so build_runner regenerates from scratch:

bash
dart run build_runner build --delete-conflicting-outputs

Analyzer still reports zero errors after a known-bad annotation change The repo's analysis_options.yaml excludes **/*.entity.dart and **/*.server.dart. Either hot-restart the running app to surface runtime errors, or temporarily remove those exclusions and re-run the analyzer.

What's next?