Skip to content

Best Practices

The recurring patterns that keep entity-system applications maintainable, type-safe, and performant.

Configuration Organization

One Folder Per Entity

Group everything for an entity into one folder. The annotation-driven generator emits *.entity.dart, *.g.dart is JSON serialization. If you have layout overrides, keep them in a layouts/ subfolder:

lib/
  entities/
    course/
      course.dart            # Model + @Entity annotation
      course.g.dart          # Generated JSON serialization
      course.entity.dart     # Generated fields, base class, $courseConfig
      course_api.dart        # CourseApi extends HttpEntityApi<Course>
      course_config.dart     # Feature-local override of $courseConfig
      layouts/
        course_detail_layout.dart
        course_table_layout.dart

Single Registration Point Per Feature

Register all of a feature's entities in one EntityExtensionDescriptor:

dart
EntityExtensionDescriptor(
  entities: [
    courseConfig,
    participantConfig,
    trainerConfig,
    certificationConfig,
    enrollmentConfig,
  ],
  services: [
    EntityServiceRegistration<EnrollmentCountService>(
      EnrollmentCountService(),
    ),
  ],
  fieldFormatters: [
    FieldFormat(field: 'completed_at', type: cdx.FieldType.dateTime),
  ],
)

Type Safety

Always Specify the Type Parameter

The system is built around generics. Use them everywhere:

dart
// Good
EntityConfiguration<Course>(...)
EntityActions<Course>(...)
CollectionAction<Course>(...)
EntityAction<Course>(...)
EntityTableConfig<Course>(...)

// Avoid — loses type information and breaks downstream type inference
EntityConfiguration<EntityBase>(...)

No Map<String, dynamic> for Business Data

Use typed entities at every boundary:

dart
// Avoid
final data = <String, dynamic>{'name': 'Dart 101', 'level': 'beginner'};

// Prefer
final course = Course(id: 'c1', name: 'Dart 101', level: 'beginner');

Typed APIs

HttpEntityApi<T> already gives you typed getMany, getOne, create, update, delete. Stick to it instead of building parallel typed wrappers around raw HTTP calls.

dart
class CourseApi extends HttpEntityApi<Course> {
  const CourseApi()
      : super(
          entityType: 'courses',
          schemaType: 'lms.course',
          pathBuilder: EndpointBuilder(prefix: 'courses'),
          fromJson: Course.fromJson,
          defaultSortField: 'name',
        );
}

State Management

MobX, Raw Observables

The entity system stores use raw Observable<T>, runInAction, and Computed<T> — no @observable / @action codegen. Match that style in your own stores, expose state through getters, and never expose Observable instances to widgets.

dart
class CourseStore {
  final _courses = Observable<List<Course>>(const []);
  List<Course> get courses => _courses.value;

  Future<void> load() async {
    final result = await _api.getMany();
    runInAction(() => _courses.value = result);
  }
}

Never setState Inside Entity Widgets

Use MobX Observer (or EntityProvider) — you get fine-grained reactivity for free, and the entity controllers expect it.

Field Definitions

Use the Generator's EntityFieldRegistry

The generator emits CourseFields with one static FieldDefinition<Course> per @Field. Reference those in layouts and forms — never re-create them by hand.

dart
EntityTableColumn<Course>(fieldDef: CourseFields.name)
EntityTableColumn<Course>(fieldDef: CourseFields.status)

Set Column Priority Intentionally

ColumnPriority controls which table columns hide first on narrow screens:

ConstantWhen to use
ColumnPriority.essentialName, primary identifier — never hidden
ColumnPriority.highStatus, key classification fields
ColumnPriority.normalDefault — hidden on tablet
ColumnPriority.lowMetadata, timestamps, counts
dart
@Field(label: 'Name', priority: ColumnPriority.essential)
final String name;

@Field(label: 'Last Activity', priority: ColumnPriority.low)
final DateTime? lastActivityAt;

Mark Filterable / Sortable Fields

Without filterable: true a field will not show up in the filter dialog or the URL filter sync. Without sortable: true the column header click is a no-op. Set both intentionally.

Authorization Design

Start with OpenAuthorizationProvider

In development, every Authorize expression should pass. Use the open provider to focus on shipping features first:

dart
EntitySystemPlugin(
  baseUrl: 'https://api.example.com',
  authorizationProvider: OpenAuthorizationProvider(),
);

Layer Authorization In, Don't Bolt It On

When you wire a real provider, gate in this order:

  1. RoutesEntityRouting.permissions blocks list / create / view / edit / dashboard URLs.
  2. ActionsEntityAction.authorize and CollectionAction.authorize hide buttons.
  3. Detail tabs and layoutsEntityLayout.authorize keeps tabs out of the bar entirely.
  4. Custom subtrees — wrap with AuthorizeGate or AuthorizeGuard from vyuh_entity_system_ui.
dart
// Routes
routing: EntityRouting<Course>(
  path: NavigationPathBuilder.collection(prefix: '/lms/courses'),
  builder: StandardRouteBuilder<Course>(),
  permissions: EntityRoutePermissions(
    list: ['lms.courses.view'],
    create: ['lms.courses.create'],
    edit: ['lms.courses.update'],
  ),
),

// Actions
CollectionAction<Course>(
  icon: FluentIcons.publish_24_regular,
  title: 'Publish',
  authorize: Authorize.permission('lms.courses.publish'),
  handler: (context) async { /* ... */ },
)

// Layouts
EntityVersionLayout<Course>(
  authorize: Authorize.permission('lms.courses.update'),
)

Use Authorize Combinators, Not Strings

The DSL composes:

dart
Authorize.all([
  Authorize.permission('lms.courses.update'),
  Authorize.anyPermission(['lms.courses.publish', 'lms.courses.archive']),
])

Never assemble permission strings with string concatenation — it bypasses the type checker and the provider's caching layer.

Permission Naming Convention

Follow {module}.{entity}.{verb} (singular verb, present tense, lowercase):

lms.courses.view        lms.courses.create
lms.courses.update      lms.courses.delete
lms.courses.publish     lms.courses.archive
lms.participants.view   lms.participants.manage

Layouts

Start Simple, Add Surfaces As You Need Them

dart
// Phase 1
EntityLayouts<Course>(list: [courseTableLayout])

// Phase 2 — add detail tabs
EntityLayouts<Course>(
  list: [courseTableLayout],
  details: [courseDetailLayout, courseParticipantsLayout],
)

// Phase 3 — add audit + version trail
EntityLayouts<Course>(
  list: [courseTableLayout],
  details: [
    courseDetailLayout,
    courseParticipantsLayout,
    EntityVersionLayout<Course>(),
    EntityAuditLayout<Course>(),
  ],
)

EntityVersionLayout and EntityAuditLayout are first-class detail layouts shipped by vyuh_entity_system_ui — wire them in directly when the entity mixes in Versionable.

Prefer Built-in Cells

vyuh_entity_system_ui ships pre-built cells: DraftableEntityNameCell, EntityNameText, SearchableTableText, EntityDraftBadge. Reach for these before writing custom cell builders.

Routing

Never Hardcode Entity URLs

Always go through the entity config:

dart
// Good
final route = vyuh.entity?.getConfig<Course>()?.route;
context.go(route!.view(courseId));
context.go(route.create(queryParameters: {'template_id': 't-42'}));
context.go(route.list());

// Bad
context.go('/lms/courses/$courseId');

The route getter is just routing.path — see Navigation.

Caching and Realtime

Use the Built-in Cache, Invalidate Targeted

HttpEntityApi<T> is wired to the query cache. After mutations, invalidate narrowly:

dart
final queryCache = vyuh.entity?.services.queryCache;
queryCache?.invalidateById('courses', courseId); // Single row
queryCache?.invalidateOperation('courses', 'list'); // List page
queryCache?.invalidateEntity('courses'); // Whole entity

Avoid clearAll() — it nukes every entity's cache.

Cross-Entity Invalidation

If updating Course also affects an Enrollment list, the API method that owns the mutation should announce both. Read sites must not duplicate the invalidation logic.

Pause Realtime Subscriptions

RealtimeConfig.pauseWhenInactive defaults to true. Keep it that way — it saves websocket / polling traffic when the user is not on the page.

Filters

Always Use the Discriminated-Union Format

Every getMany(filters: …) call uses the same JSON shape:

dart
final filters = {
  'schema_type': 'cdx.query.filter.group',
  'op': 'and',
  'children': [
    {'schema_type': 'cdx.query.filter.condition', 'field': 'status', 'operator': 'equals', 'value': 'published'},
    {'schema_type': 'cdx.query.filter.condition', 'field': 'level',  'operator': 'inList', 'value': ['intermediate', 'advanced']},
  ],
};
await courseApi.getMany(filters: filters);

Operators: equals, notEquals, inList, notInList, contains, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual, between, isNull, isNotNull.

Error Handling

Always Funnel Through ErrorDisplayService

vyuh.entity?.services.errorDisplay knows how to surface errors as banners, dialogs, inline messages, or pages. Never raise raw exception strings to the user:

dart
try {
  await courseApi.publish(course.id);
} catch (e, stack) {
  if (context.mounted) {
    vyuh.entity?.services.errorDisplay.showException(
      context,
      e,
      stackTrace: stack,
      title: 'Could not publish course',
      useDialog: true,
    );
  }
}

Never Use SnackBars

Use a confirmation dialog, an inline banner, or a reactive UI refresh. SnackBars are explicitly out of bounds in this stack.

Codegen Hygiene

  • Add **/*.entity.dart to analyzer.exclude — the generator emits permissive types that should never be linted.
  • After every change to an @Entity / @Field / @JsonSerializable annotation, run dart run build_runner build --delete-conflicting-outputs before considering the change done.
  • Never edit *.entity.dart or *.g.dart by hand — change the annotation or write a custom override in *_config.dart.

Next Steps