Skip to content

Defining Entities

This guide walks through declaring entity models for an LMS (Learning Management System). You will define Course, Participant, and Trainer models, choose the right capability mixins, configure metadata, register field definitions, and model cross-entity relationships.

Two Ways to Define an Entity

The framework offers two equivalent paths:

  1. Annotation-driven — annotate the class with @Entity(...) and @Field(...), run the generator, and extend the generated $NameBase class. This is the canonical path used in flutter_lms. See the Annotations guide and Generator guide.
  2. Hand-written — extend EntityBase directly and apply the capability mixins (Auditable, Versionable, Draftable, HasDescription) yourself. Use this when you need full control over the JSON shape, or when you don't want code generation.

This guide focuses on the hand-written path. Every concept transfers to the annotation path — the generator emits exactly this structure.

EntityBase Surface

EntityBase is intentionally minimal. It provides only the universal identity fields:

dart
abstract class EntityBase extends ContentItem {
  @JsonKey(readValue: readFieldValue)
  final String id;

  @JsonKey(readValue: readFieldValue)
  final String name;

  EntityBase({
    required this.id,
    this.name = '',
    required super.schemaType,
    required super.layout,
    required super.modifiers,
  });

  Map<String, dynamic> toJson();

  String get displayTitle => name;
  String get displaySubtitle => '';
  Map<String, String> get displayProperties => {};
}

Audit columns, version columns, and draft metadata are added by capability mixins, not by EntityBase itself. This keeps the base class small and makes it explicit which columns each entity has.

Capability Mixins

MixinAddsWhen to use
AuditablecreatedAt, updatedAt, createdBy, updatedByMost business entities
Versionable (on EntityBase)versionNumber, isActiveEntities that need version history
Draftable (on Versionable)draftMetadata, isEntityDraft, hasModificationDraftEntities that go through approval workflows
HasDescriptiondescription getterAnything that surfaces a free-text description
HasCodecode getterMaster-data entities with a short business code
HasActiveStatusisActive getterQuick is-active checks across mixed types
HasStatusstatus getterEntities with a state-machine status string

Versionable is the prerequisite for Draftable. Both mixins declare abstract fields — your concrete class is responsible for materializing them with @JsonKey annotations (or the generator does it for you in $NameBase).

Defining a Course Entity

dart
import 'package:json_annotation/json_annotation.dart';
import 'package:vyuh_entity_system/vyuh_entity_system.dart';

part 'course.g.dart';

@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false)
class Course extends EntityBase
    with Auditable, Versionable, HasDescription {
  // ── Capability fields ────────────────────────────────────────
  @override
  @JsonKey(readValue: readFieldValue)
  final DateTime? createdAt;

  @override
  @JsonKey(readValue: readFieldValue)
  final DateTime? updatedAt;

  @override
  @JsonKey(readValue: readFieldValue)
  final String? createdBy;

  @override
  @JsonKey(readValue: readFieldValue)
  final String? updatedBy;

  @override
  @JsonKey(readValue: readFieldValue, defaultValue: 1, includeToJson: false)
  final int versionNumber;

  @override
  @JsonKey(readValue: readFieldValue, defaultValue: true, includeToJson: false)
  final bool isActive;

  @override
  final String? description;

  // ── Domain fields ────────────────────────────────────────────
  final String level;            // beginner, intermediate, advanced
  final String status;           // draft, published, archived
  final int durationMinutes;

  /// Foreign-key column to `trainers.id`.
  final String? instructorId;

  /// Server-projected reference (joined by PostgREST).
  /// `includeToJson: false` keeps the FK round-trip clean.
  @JsonKey(includeToJson: false)
  final EntityReference? instructor;

  Course({
    required super.id,
    required super.name,
    this.description,
    this.level = 'beginner',
    this.status = 'draft',
    this.durationMinutes = 0,
    this.instructorId,
    this.instructor,
    this.createdAt,
    this.updatedAt,
    this.createdBy,
    this.updatedBy,
    this.versionNumber = 1,
    this.isActive = true,
  }) : super(
          schemaType: 'lms.course',
          layout: null,
          modifiers: null,
        );

  factory Course.fromJson(Map<String, dynamic> json) =>
      _$CourseFromJson(json);

  @override
  Map<String, dynamic> toJson() => _$CourseToJson(this);

  @override
  String get displayTitle => name;

  @override
  String get displaySubtitle => '$level - ${durationMinutes}min';

  @override
  Map<String, String> get displayProperties => {
        'Level': level,
        'Status': status,
        'Duration': '${durationMinutes}min',
        if (instructor != null) 'Instructor': instructor!.name,
      };
}

Why readValue: readFieldValue?

readFieldValue is a unified JSON read helper exported by vyuh_entity_system. It accepts both camelCase (createdAt) and the configured snake_case column name (created_at). This lets the same model accept payloads from different transport layers without forcing a single naming convention. Always use it on identity, audit, and version fields.

If you need to override the column name globally (e.g., a non-standard schema uses inserted_at instead of created_at), call FieldKeyMap.keyMap = {...} once at app startup.

Required Super Parameters

EntityBase extends ContentItem from vyuh_core, which requires:

  • schemaType — schema-qualified identifier (e.g., 'lms.course')
  • layout — pass null for entities (used only by content types)
  • modifiers — pass null for entities (used only by content types)

Defining a Participant Entity

A simple entity with audit but no versioning or drafts:

dart
@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false)
class Participant extends EntityBase with Auditable {
  @override
  @JsonKey(readValue: readFieldValue)
  final DateTime? createdAt;

  @override
  @JsonKey(readValue: readFieldValue)
  final DateTime? updatedAt;

  @override
  @JsonKey(readValue: readFieldValue)
  final String? createdBy;

  @override
  @JsonKey(readValue: readFieldValue)
  final String? updatedBy;

  final String email;
  final String role;                   // student, auditor
  final List<String> enrolledCourses;  // Course IDs

  Participant({
    required super.id,
    required super.name,
    required this.email,
    this.role = 'student',
    this.enrolledCourses = const [],
    this.createdAt,
    this.updatedAt,
    this.createdBy,
    this.updatedBy,
  }) : super(
          schemaType: 'lms.participant',
          layout: null,
          modifiers: null,
        );

  factory Participant.fromJson(Map<String, dynamic> json) =>
      _$ParticipantFromJson(json);

  @override
  Map<String, dynamic> toJson() => _$ParticipantToJson(this);

  @override
  String get displaySubtitle => email;

  @override
  Map<String, String> get displayProperties => {
        'Email': email,
        'Role': role,
        'Enrolled': '${enrolledCourses.length} courses',
      };
}

Defining a Trainer Entity

dart
@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false)
class Trainer extends EntityBase with Auditable, HasActiveStatus {
  @override
  @JsonKey(readValue: readFieldValue)
  final DateTime? createdAt;

  @override
  @JsonKey(readValue: readFieldValue)
  final DateTime? updatedAt;

  @override
  @JsonKey(readValue: readFieldValue)
  final String? createdBy;

  @override
  @JsonKey(readValue: readFieldValue)
  final String? updatedBy;

  @override
  @JsonKey(readValue: readFieldValue, defaultValue: true)
  final bool isActive;

  final String email;
  final List<String> specializations;
  final List<String> assignedCourses;
  final double rating;

  Trainer({
    required super.id,
    required super.name,
    required this.email,
    this.specializations = const [],
    this.assignedCourses = const [],
    this.rating = 0.0,
    this.isActive = true,
    this.createdAt,
    this.updatedAt,
    this.createdBy,
    this.updatedBy,
  }) : super(
          schemaType: 'lms.trainer',
          layout: null,
          modifiers: null,
        );

  factory Trainer.fromJson(Map<String, dynamic> json) =>
      _$TrainerFromJson(json);

  @override
  Map<String, dynamic> toJson() => _$TrainerToJson(this);

  @override
  String get displaySubtitle => specializations.join(', ');

  @override
  Map<String, String> get displayProperties => {
        'Email': email,
        'Rating': rating.toStringAsFixed(1),
        'Courses': '${assignedCourses.length} assigned',
      };
}

JSON Serialization

After declaring or modifying a model, run build_runner to generate the *.g.dart files:

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

This produces _$CourseFromJson, _$CourseToJson, etc. for every @JsonSerializable class.

Field Rename Convention

Always use fieldRename: FieldRename.snake. The HTTP transport, PostgREST, and the database all speak snake_case. Dart fields stay camelCase.

Common @JsonKey Patterns

dart
// Server-projected reference column never sent on writes.
@JsonKey(name: 'instructor', includeToJson: false)
final EntityReference? instructor;

// Capability fields managed by DB triggers — never write them.
@JsonKey(readValue: readFieldValue, includeToJson: false, defaultValue: 1)
final int versionNumber;

// Draft metadata is server-only.
@JsonKey(includeToJson: false)
DraftMetadata? draftMetadata;

EntityMetadata

EntityMetadata describes an entity type for the framework — display names, icon, category, and search behavior.

dart
final courseMetadata = EntityMetadata(
  identifier: 'courses',
  name: 'Course',
  pluralName: 'Courses',
  icon: Icons.menu_book,
  description: 'Training courses for participants',
  category: lmsCategory,
  priority: 10,
  visibility: UIVisibility.all(menuCategory: lmsMasterData),
);

Key Properties

PropertyPurpose
identifierUnique string used for permissions, routing, and API calls
name / pluralNameDisplay names (singular/plural)
iconIcon in menus, search results, and headers
categoryTop-level grouping in the navigation menu
prioritySort order in menus (lower numbers first)
visibilitySet of UIVisibility surfaces (menu, route, search, dashboard)
isSingletonWhether only one instance exists (e.g., Settings)
schemaTypeOptional schema-qualified name (e.g., 'lms.course')
alternateIdentifiersAlternate IDs for flexible lookups

Localization

Resolve names through callbacks for runtime locale switching:

dart
final courseMetadata = EntityMetadata(
  identifier: 'courses',
  name: 'Course',
  nameResolver: () => t.strings.lms.entities.course.singular,
  pluralName: 'Courses',
  pluralNameResolver: () => t.strings.lms.entities.course.plural,
);

The literal name / pluralName serve as fallbacks when the resolver returns null.

Field Definitions

Field definitions describe each column once. The framework reuses them for table columns, filter dialogs, card displays, and audit-trail formatting.

dart
class CourseFields extends EntityFieldRegistry<Course> {
  const CourseFields._();
  static const instance = CourseFields._();

  static final name = TextFieldDef<Course>(
    field: 'name',
    label: 'Name',
    required: true,
    primary: true,
    sortable: true,
    priority: ColumnPriority.essential,
  );

  static final level = EnumFieldDef<Course>(
    field: 'level',
    label: 'Level',
    filterable: true,
    options: {
      'beginner': 'Beginner',
      'intermediate': 'Intermediate',
      'advanced': 'Advanced',
    },
    colorMap: {
      'beginner': Colors.green,
      'intermediate': Colors.orange,
      'advanced': Colors.red,
    },
  );

  static final status = EnumFieldDef<Course>(
    field: 'status',
    label: 'Status',
    filterable: true,
    options: {
      'draft': 'Draft',
      'published': 'Published',
      'archived': 'Archived',
    },
  );

  static final duration = NumberFieldDef<Course>(
    field: 'duration_minutes',
    label: 'Duration',
    suffix: 'min',
    priority: ColumnPriority.low,
  );

  static final instructor = ReferenceFieldDef<Course>.auto(
    foreignKeyField: 'instructor_id',
    targetField: 'name',
    targetEntityType: 'trainers',
    label: 'Instructor',
    filterable: true,
  );

  @override
  List<FieldDefinition<Course>> get all =>
      [name, level, status, duration, instructor];
}

FieldDefinition Subclasses

TypeUse caseExtra properties
TextFieldDefStringsmaxLines, maxLength
NumberFieldDefIntegers and decimalsprefix, suffix, min, max, allowDecimal
EnumFieldDefFixed-option fieldsoptions, colorMap, iconMap, displayAs
BooleanFieldDefTrue/false fieldstrueLabel, falseLabel, displayAs
DateFieldDefDate-only fieldsformat, minDate, maxDate
DateTimeFieldDefDate and time fieldsformat, minDateTime, maxDateTime
ReferenceFieldDefForeign-key relationshipsforeignKeyField, targetField, targetEntityType

Reference these field definitions from EntityTableColumn(fieldDef: ...) so the table layout is type-safe — you can only display columns for fields that actually exist.

Column Priority

ColumnPriority controls which columns hide first on narrow screens.

ConstantValueMeaning
ColumnPriority.essential0Never hidden
ColumnPriority.high1Hidden only on very narrow screens
ColumnPriority.normal2Default
ColumnPriority.low3Hidden first

Width and minWidth belong on the column, not the field

FieldDefinition is data-only. Relative widths and minWidth constraints belong on EntityTableColumn(fieldDef: AreaFields.name, widthFactor: 0.25, minWidth: 180) so the same field can render with different widths in different tables.

EntityReference for Relationships

EntityReference models a cross-entity link with the referenced entity's id plus its display name resolved server-side. The UI shows the trainer's name without an extra round-trip.

dart
class Course extends EntityBase {
  /// FK column written to the database.
  final String? instructorId;

  /// Server-projected reference (read-only on the client).
  @JsonKey(includeToJson: false)
  final EntityReference? instructor;
}

For field definitions, ReferenceFieldDef.auto builds the joined field path:

dart
static final instructor = ReferenceFieldDef<Course>.auto(
  foreignKeyField: 'instructor_id',
  targetField: 'name',
  targetEntityType: 'trainers',
  label: 'Instructor',
);
// Produces field path: 'instructor_id.name'

PostgREST joins instructor_id with the trainers table and projects the name column. The UI consumes the joined value through EntityReference.

Display Properties

displayTitle, displaySubtitle, and displayProperties are read by every generic surface — mobile compact rows, related-entity cards, command-palette previews, search results.

dart
@override
Map<String, String> get displayProperties => {
      'Level': level,
      'Status': status,
      'Duration': '${durationMinutes}min',
      if (instructor != null) 'Instructor': instructor!.name,
    };

Keys are human-readable labels; values are pre-formatted strings. Don't use this to render numbers without units, dates without formatting, or raw IDs.

Annotation Path Equivalent

The same Course declared with annotations:

dart
@Entity(
  schema: 'lms',
  identifier: 'courses',
  name: 'Course',
  pluralName: 'Courses',
  icon: Icons.menu_book,
  capabilities: {EntityCapability.versionable()},
  navigation: EntityNavigation(prefix: '/lms/courses'),
)
@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false)
class Course extends $CourseBase with HasDescription {
  @Field(label: 'Description', type: TextFieldType(lines: 3))
  @override
  final String? description;

  @Field(label: 'Level', filterable: true)
  final String level;

  // ...

  Course({required super.id, required super.name, /* ... */ });

  factory Course.fromJson(Map<String, dynamic> json) =>
      _$CourseFromJson(json);

  @override
  Map<String, dynamic> toJson() => _$CourseToJson(this);
}

The generator produces $CourseBase with the audit, version, and draft fields based on capabilities, plus a CourseFields class with FieldDefinitions derived from @Field. See Annotations and Generator.

Next Steps