Complete LMS Example
This walkthrough builds a complete Learning Management System with the Vyuh Entity System: five related entities, their typed APIs, the authorization matrix, relationship wiring, and the feature registration.
The code uses the annotation-driven path. The generator emits *.entity.dart for each entity (field definitions, API base, default table layout, default form, and the wired $entityConfig); we override only the slots that need richer presentation.
Entity Relationship Diagram
Shared Setup
Define the LMS menu category once. Every entity references it.
import 'package:fluentui_system_icons/fluentui_system_icons.dart';
import 'package:vyuh_entity_annotations/vyuh_entity_annotations.dart';
import 'package:vyuh_entity_system/vyuh_entity_system.dart';
const lmsCategory = EntityCategory(
id: 'lms',
name: 'Learning Management',
icon: FluentIcons.hat_graduation_24_regular,
schema: 'lms',
);
const lmsMenuCategory = MenuCategory(
id: 'lms',
title: 'Learning',
icon: FluentIcons.hat_graduation_24_regular,
sortOrder: 10,
);Entity Models
Each model is a @JsonSerializable class that extends a generated $EntityBase (the generator emits the right capability mixins from @Entity(capabilities: ...)).
Course
@Entity(
schema: 'lms',
identifier: 'courses',
name: 'Course',
pluralName: 'Courses',
description: 'Training courses for participants',
icon: FluentIcons.book_24_regular,
priority: 10,
category: lmsCategory,
menuCategory: lmsMenuCategory,
capabilities: {EntityCapability.draftable()},
visibility: EntityVisibility.all,
navigation: EntityNavigation(
prefix: '/lms/courses',
authorize: AuthorizeEntity(
list: AuthorizeRule(Authorize.permission('lms.courses.view')),
create: AuthorizeRule(Authorize.permission('lms.courses.create')),
view: AuthorizeRule(Authorize.permission('lms.courses.view')),
edit: AuthorizeRule(Authorize.permission('lms.courses.update')),
),
),
)
@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false)
class Course extends $CourseBase {
@Field(label: 'Description', type: TextFieldType(lines: 3))
final String? description;
@Field(label: 'Level', filterable: true, sortable: true)
final String level;
@Field(label: 'Status', filterable: true, sortable: true)
final String status;
@Field(label: 'Duration (min)', sortable: true)
final int durationMinutes;
@Field(
label: 'Instructor',
filterable: true,
type: ReferenceFieldType(entityType: 'trainers'),
)
final String? instructorId;
@JsonKey(name: 'instructor', 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,
super.versionNumber,
super.isActive,
super.createdAt,
super.updatedAt,
super.createdBy,
super.updatedBy,
});
factory Course.fromJson(Map<String, dynamic> json) =>
_$CourseFromJson(json);
@override
Map<String, dynamic> toJson() => _$CourseToJson(this);
}Participant
@Entity(
schema: 'lms',
identifier: 'participants',
name: 'Participant',
pluralName: 'Participants',
icon: FluentIcons.person_24_regular,
priority: 20,
category: lmsCategory,
menuCategory: lmsMenuCategory,
visibility: EntityVisibility.all,
navigation: EntityNavigation(
prefix: '/lms/participants',
authorize: AuthorizeEntity(
list: AuthorizeRule(Authorize.permission('lms.participants.view')),
create: AuthorizeRule(Authorize.permission('lms.participants.manage')),
edit: AuthorizeRule(Authorize.permission('lms.participants.manage')),
),
),
)
@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false)
class Participant extends $ParticipantBase {
@Field(label: 'Email', filterable: true, sortable: true)
final String email;
@Field(label: 'Role', filterable: true)
final String role; // student | auditor
Participant({
required super.id,
required super.name,
required this.email,
this.role = 'student',
super.createdAt,
super.updatedAt,
super.createdBy,
super.updatedBy,
});
factory Participant.fromJson(Map<String, dynamic> json) =>
_$ParticipantFromJson(json);
@override
Map<String, dynamic> toJson() => _$ParticipantToJson(this);
}Trainer
@Entity(
schema: 'lms',
identifier: 'trainers',
name: 'Trainer',
pluralName: 'Trainers',
icon: FluentIcons.person_chat_24_regular,
priority: 30,
category: lmsCategory,
menuCategory: lmsMenuCategory,
visibility: EntityVisibility.all,
navigation: EntityNavigation(
prefix: '/lms/trainers',
authorize: AuthorizeEntity(
list: AuthorizeRule(Authorize.permission('lms.trainers.view')),
create: AuthorizeRule(Authorize.permission('lms.trainers.manage')),
edit: AuthorizeRule(Authorize.permission('lms.trainers.manage')),
),
),
)
@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false)
class Trainer extends $TrainerBase {
@Field(label: 'Email', filterable: true, sortable: true)
final String email;
@Field(label: 'Specializations')
final List<String> specializations;
@Field(label: 'Rating', sortable: true)
final double rating;
Trainer({
required super.id,
required super.name,
required this.email,
this.specializations = const [],
this.rating = 0.0,
super.createdAt,
super.updatedAt,
super.createdBy,
super.updatedBy,
});
factory Trainer.fromJson(Map<String, dynamic> json) =>
_$TrainerFromJson(json);
@override
Map<String, dynamic> toJson() => _$TrainerToJson(this);
}Enrollment
@Entity(
schema: 'lms',
identifier: 'enrollments',
name: 'Enrollment',
pluralName: 'Enrollments',
icon: FluentIcons.checkbox_person_24_regular,
priority: 40,
category: lmsCategory,
menuCategory: lmsMenuCategory,
visibility: EntityVisibility.all,
navigation: EntityNavigation(
prefix: '/lms/enrollments',
authorize: AuthorizeEntity(
list: AuthorizeRule(Authorize.permission('lms.enrollments.view')),
create: AuthorizeRule(Authorize.permission('lms.enrollments.manage')),
edit: AuthorizeRule(Authorize.permission('lms.enrollments.manage')),
),
),
)
@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false)
class Enrollment extends $EnrollmentBase {
@Field(label: 'Course', type: ReferenceFieldType(entityType: 'courses'))
final String courseId;
@Field(
label: 'Participant',
type: ReferenceFieldType(entityType: 'participants'),
)
final String participantId;
@Field(label: 'Status', filterable: true, sortable: true)
final String status; // enrolled | in_progress | completed | dropped
@Field(label: 'Progress %', sortable: true)
final int progressPercent;
@Field(label: 'Enrolled', sortable: true)
final DateTime? enrolledAt;
@Field(label: 'Completed', sortable: true)
final DateTime? completedAt;
@JsonKey(name: 'course', includeToJson: false)
final EntityReference? course;
@JsonKey(name: 'participant', includeToJson: false)
final EntityReference? participant;
Enrollment({
required super.id,
required super.name,
required this.courseId,
required this.participantId,
this.status = 'enrolled',
this.progressPercent = 0,
this.enrolledAt,
this.completedAt,
this.course,
this.participant,
super.createdAt,
super.updatedAt,
super.createdBy,
super.updatedBy,
});
factory Enrollment.fromJson(Map<String, dynamic> json) =>
_$EnrollmentFromJson(json);
@override
Map<String, dynamic> toJson() => _$EnrollmentToJson(this);
}Certification
@Entity(
schema: 'lms',
identifier: 'certifications',
name: 'Certification',
pluralName: 'Certifications',
icon: FluentIcons.ribbon_24_regular,
priority: 50,
category: lmsCategory,
menuCategory: lmsMenuCategory,
capabilities: {EntityCapability.draftable()},
visibility: EntityVisibility.all,
navigation: EntityNavigation(
prefix: '/lms/certifications',
authorize: AuthorizeEntity(
list: AuthorizeRule(Authorize.permission('lms.certifications.view')),
create: AuthorizeRule(Authorize.permission('lms.certifications.approve')),
edit: AuthorizeRule(Authorize.permission('lms.certifications.approve')),
),
),
)
@JsonSerializable(fieldRename: FieldRename.snake, includeIfNull: false)
class Certification extends $CertificationBase {
@Field(label: 'Course', type: ReferenceFieldType(entityType: 'courses'))
final String courseId;
@Field(
label: 'Participant',
type: ReferenceFieldType(entityType: 'participants'),
)
final String participantId;
@Field(label: 'Status', filterable: true, sortable: true)
final String status; // draft | under_review | approved | issued | expired | revoked
@Field(label: 'Certificate #', sortable: true)
final String? certificateNumber;
@Field(label: 'Issued', sortable: true)
final DateTime? issuedAt;
@Field(label: 'Expires', sortable: true)
final DateTime? expiresAt;
@JsonKey(name: 'course', includeToJson: false)
final EntityReference? course;
@JsonKey(name: 'participant', includeToJson: false)
final EntityReference? participant;
Certification({
required super.id,
required super.name,
required this.courseId,
required this.participantId,
this.status = 'draft',
this.certificateNumber,
this.issuedAt,
this.expiresAt,
this.course,
this.participant,
super.versionNumber,
super.isActive,
super.createdAt,
super.updatedAt,
super.createdBy,
super.updatedBy,
});
factory Certification.fromJson(Map<String, dynamic> json) =>
_$CertificationFromJson(json);
@override
Map<String, dynamic> toJson() => _$CertificationToJson(this);
}API Implementations
Each API extends the generated $<Entity>ApiBase (which the generator emits with the right entityType, schemaType, pathBuilder, and fromJson). Override only when you need custom endpoints.
// course_api.dart
class CourseApi extends $CourseApiBase {
const CourseApi();
Future<Course?> updateStatus(String id, String newStatus) async {
final uri = Uri.parse('${pathBuilder.one(id)}/status');
final res = await vyuh.network.patch(
uri,
headers: defaultHeaders,
body: '{"status": "$newStatus"}',
);
if (res.statusCode != 200) return null;
final cache = vyuh.entity?.services.queryCache;
cache?.invalidateById('courses', id);
cache?.invalidateOperation('courses', 'list');
final json = jsonDecode(res.body) as Map<String, dynamic>;
return Course.fromJson(json['data'] as Map<String, dynamic>);
}
}
// other apis follow the same shape
class ParticipantApi extends $ParticipantApiBase { const ParticipantApi(); }
class TrainerApi extends $TrainerApiBase { const TrainerApi(); }
class EnrollmentApi extends $EnrollmentApiBase { const EnrollmentApi(); }
class CertificationApi extends $CertificationApiBase { const CertificationApi(); }If you skip code generation, hand-roll the same class as extends HttpEntityApi<Course> with the explicit constructor — see the Course Management example.
Entity Configurations
The generator emits $courseConfig, $participantConfig, etc. with all the wiring done. Most apps override only the slots that need feature- specific richness. The example below shows the Course override; the other entities follow the same shape:
import 'package:vyuh_entity_system_ui/vyuh_entity_system_ui.dart';
import 'course.dart';
import 'course.entity.dart' as base show $courseConfig;
final EntityConfiguration<Course> courseConfig = base.$courseConfig.copyWith(
layouts: EntityLayouts<Course>(
list: [
EntityTableConfig<Course>(
identifier: 'table',
title: 'Courses',
icon: FluentIcons.table_24_regular,
columns: [
EntityTableColumn<Course>(
fieldDef: CourseFields.name,
cellBuilder: (c, course) => DraftableEntityNameCell<Course>(
entity: course, name: course.name,
),
),
EntityTableColumn<Course>(fieldDef: CourseFields.level),
EntityTableColumn<Course>(fieldDef: CourseFields.status),
EntityTableColumn<Course>(fieldDef: CourseFields.durationMinutes),
],
enableMultiSelect: true,
),
],
details: [
const CourseDetailLayout(),
const CourseParticipantsLayout(),
EntityVersionLayout<Course>(
authorize: Authorize.permission('lms.courses.update'),
),
EntityAuditLayout<Course>(
authorize: Authorize.permission('lms.courses.update'),
),
],
),
actions: base.$courseConfig.actions.copyWith(
collection: [
CollectionAction<Course>(
icon: FluentIcons.checkmark_circle_24_regular,
title: 'Publish All Drafts',
isPrimary: true,
authorize: Authorize.permission('lms.courses.publish'),
handler: (context) async {
await const CourseApi().publishAllDrafts();
vyuh.entity?.services.queryCache?.invalidateEntity('courses');
},
),
],
),
);The participant, trainer, enrollment, and certification overrides have the same structure — replace the Course-specific layouts with their counterparts (ParticipantTableLayout, EnrollmentTableLayout, etc.).
Authorization Matrix
Three roles with progressively broader permissions:
| Permission | Administrator | Trainer | Participant |
|---|---|---|---|
lms.courses.view | yes | yes | yes |
lms.courses.create | yes | — | — |
lms.courses.update | yes | yes | — |
lms.courses.delete | yes | — | — |
lms.courses.publish | yes | — | — |
lms.participants.view | yes | yes | yes |
lms.participants.manage | yes | — | — |
lms.trainers.view | yes | yes | — |
lms.trainers.manage | yes | — | — |
lms.enrollments.view | yes | yes | yes |
lms.enrollments.manage | yes | yes | — |
lms.certifications.view | yes | yes | yes |
lms.certifications.approve | yes | — | — |
AuthorizationProvider Implementation
class LmsAuthorizationProvider extends AuthorizationProvider {
static const _rolePermissions = {
'administrator': {
'lms.courses.view', 'lms.courses.create', 'lms.courses.update',
'lms.courses.delete', 'lms.courses.publish',
'lms.participants.view', 'lms.participants.manage',
'lms.trainers.view', 'lms.trainers.manage',
'lms.enrollments.view', 'lms.enrollments.manage',
'lms.certifications.view', 'lms.certifications.approve',
},
'trainer': {
'lms.courses.view', 'lms.courses.update',
'lms.participants.view',
'lms.trainers.view',
'lms.enrollments.view', 'lms.enrollments.manage',
'lms.certifications.view',
},
'participant': {
'lms.courses.view',
'lms.participants.view',
'lms.enrollments.view',
'lms.certifications.view',
},
};
final SessionService _session;
Set<String> _permissions = const {};
Set<String> _roles = const {};
LmsAuthorizationProvider(this._session);
@override
Future<void> load() async {
final user = await _session.currentUser();
_roles = user.roles.toSet();
_permissions = {
for (final role in _roles) ..._rolePermissions[role] ?? const <String>{},
};
markReady();
}
@override
bool hasPermission(String permission) => _permissions.contains(permission);
@override
bool hasRole(String role) => _roles.contains(role);
}Registration
class LmsFeature extends FeatureDescriptor {
LmsFeature()
: super(
name: 'lms',
title: 'Learning Management System',
extensions: [
EntityExtensionDescriptor(
entities: [
courseConfig,
participantConfig,
trainerConfig,
enrollmentConfig,
certificationConfig,
],
services: [
EntityServiceRegistration<EnrollmentCountService>(
EnrollmentCountService(),
),
],
),
],
);
}And the platform setup:
final entityPlugin = EntitySystemPlugin(
baseUrl: 'https://api.example.com/lms',
authorizationProvider: LmsAuthorizationProvider(sessionService),
);
vyuh.runApp(
plugins: PluginDescriptor(
content: DefaultContentPlugin(...),
others: [entityPlugin],
),
features: () => [LmsFeature()],
);Generated Routes
Each EntityNavigation(prefix: ...) annotation produces:
| Prefix | Routes |
|---|---|
/lms/courses | /, /new, /:id, /:id/edit, /dashboard |
/lms/participants | /, /new, /:id, /:id/edit, /dashboard |
/lms/trainers | /, /new, /:id, /:id/edit, /dashboard |
/lms/enrollments | /, /new, /:id, /:id/edit, /dashboard |
/lms/certifications | /, /new, /:id, /:id/edit, /dashboard |
Always navigate via the entity's route builder:
final route = vyuh.entity?.getConfig<Course>()?.route;
context.go(route!.list());
context.go(route.view(courseId));
context.go(route.view(courseId, const NavSlice(section: 'participants')));Relationship APIs
// Course <-> Participant (N:M via Enrollment)
final courseParticipantsApi = StandardRelatedApi(
parentIdentifier: 'courses',
relationSegment: 'participants',
requestBodyKey: 'participant_ids',
);
// Participant <-> Certification (1:N)
final participantCertificationsApi = StandardRelatedApi(
parentIdentifier: 'participants',
relationSegment: 'certifications',
requestBodyKey: 'certification_ids',
);
// Trainer <-> Course (1:N) — handled via Course.instructorId + EntityReferenceSee the Relationships page for the detail of how the layouts wire these APIs in.
Next Steps
- Course Management — CRUD walkthrough for the Course entity in depth
- Certification Flow — draft + approval + e-signature lifecycle
- Relationships — relationship patterns in depth