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:
- Annotation-driven — annotate the class with
@Entity(...)and@Field(...), run the generator, and extend the generated$NameBaseclass. This is the canonical path used influtter_lms. See the Annotations guide and Generator guide. - Hand-written — extend
EntityBasedirectly 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:
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
| Mixin | Adds | When to use |
|---|---|---|
Auditable | createdAt, updatedAt, createdBy, updatedBy | Most business entities |
Versionable (on EntityBase) | versionNumber, isActive | Entities that need version history |
Draftable (on Versionable) | draftMetadata, isEntityDraft, hasModificationDraft | Entities that go through approval workflows |
HasDescription | description getter | Anything that surfaces a free-text description |
HasCode | code getter | Master-data entities with a short business code |
HasActiveStatus | isActive getter | Quick is-active checks across mixed types |
HasStatus | status getter | Entities 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
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— passnullfor entities (used only by content types)modifiers— passnullfor entities (used only by content types)
Defining a Participant Entity
A simple entity with audit but no versioning or drafts:
@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
@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:
dart run build_runner build --delete-conflicting-outputsThis 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
// 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.
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
| Property | Purpose |
|---|---|
identifier | Unique string used for permissions, routing, and API calls |
name / pluralName | Display names (singular/plural) |
icon | Icon in menus, search results, and headers |
category | Top-level grouping in the navigation menu |
priority | Sort order in menus (lower numbers first) |
visibility | Set of UIVisibility surfaces (menu, route, search, dashboard) |
isSingleton | Whether only one instance exists (e.g., Settings) |
schemaType | Optional schema-qualified name (e.g., 'lms.course') |
alternateIdentifiers | Alternate IDs for flexible lookups |
Localization
Resolve names through callbacks for runtime locale switching:
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.
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
| Type | Use case | Extra properties |
|---|---|---|
TextFieldDef | Strings | maxLines, maxLength |
NumberFieldDef | Integers and decimals | prefix, suffix, min, max, allowDecimal |
EnumFieldDef | Fixed-option fields | options, colorMap, iconMap, displayAs |
BooleanFieldDef | True/false fields | trueLabel, falseLabel, displayAs |
DateFieldDef | Date-only fields | format, minDate, maxDate |
DateTimeFieldDef | Date and time fields | format, minDateTime, maxDateTime |
ReferenceFieldDef | Foreign-key relationships | foreignKeyField, 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.
| Constant | Value | Meaning |
|---|---|---|
ColumnPriority.essential | 0 | Never hidden |
ColumnPriority.high | 1 | Hidden only on very narrow screens |
ColumnPriority.normal | 2 | Default |
ColumnPriority.low | 3 | Hidden 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.
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:
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.
@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:
@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
- CRUD Operations — wiring the API
- Building UI — list, detail, and workspace surfaces
- Forms and Editors — single- and multi-part editors
- Drafts and Versioning —
Versionable+Draftableend to end