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, getAll, findOne, getOne, create, update, and 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. Routes — EntityRouting.permissions blocks list / create / view / edit / dashboard URLs.
  2. Actions — EntityAction.authorize and CollectionAction.authorize hide buttons.
  3. Detail tabs and layouts — EntityLayout.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);

Apply filters on the server before choosing how much data to fetch:

  • Use findOne(filters: ...) for one matching entity. Do not fetch the default page and search it in memory.
  • Use getMany(filters: ...) with explicit pagination for UI collections.
  • Use getAll(filters: ...) only when the caller truly requires the complete matching collection; it exhausts the result in bounded batches.

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 ​