Skip to content

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.

dart
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

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

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

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

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

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

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

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

PermissionAdministratorTrainerParticipant
lms.courses.viewyesyesyes
lms.courses.createyes
lms.courses.updateyesyes
lms.courses.deleteyes
lms.courses.publishyes
lms.participants.viewyesyesyes
lms.participants.manageyes
lms.trainers.viewyesyes
lms.trainers.manageyes
lms.enrollments.viewyesyesyes
lms.enrollments.manageyesyes
lms.certifications.viewyesyesyes
lms.certifications.approveyes

AuthorizationProvider Implementation

dart
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

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

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

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

dart
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

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

See the Relationships page for the detail of how the layouts wire these APIs in.

Next Steps