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.dartSingle Registration Point Per Feature
Register all of a feature's entities in one EntityExtensionDescriptor:
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:
// 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:
// 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.
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.
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.
EntityTableColumn<Course>(fieldDef: CourseFields.name)
EntityTableColumn<Course>(fieldDef: CourseFields.status)Set Column Priority Intentionally
ColumnPriority controls which table columns hide first on narrow screens:
| Constant | When to use |
|---|---|
ColumnPriority.essential | Name, primary identifier — never hidden |
ColumnPriority.high | Status, key classification fields |
ColumnPriority.normal | Default — hidden on tablet |
ColumnPriority.low | Metadata, timestamps, counts |
@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:
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:
- Routes —
EntityRouting.permissionsblocks list / create / view / edit / dashboard URLs. - Actions —
EntityAction.authorizeandCollectionAction.authorizehide buttons. - Detail tabs and layouts —
EntityLayout.authorizekeeps tabs out of the bar entirely. - Custom subtrees — wrap with
AuthorizeGateorAuthorizeGuardfromvyuh_entity_system_ui.
// 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:
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.manageLayouts
Start Simple, Add Surfaces As You Need Them
// 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:
// 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:
final queryCache = vyuh.entity?.services.queryCache;
queryCache?.invalidateById('courses', courseId); // Single row
queryCache?.invalidateOperation('courses', 'list'); // List page
queryCache?.invalidateEntity('courses'); // Whole entityAvoid 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:
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:
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.darttoanalyzer.exclude— the generator emits permissive types that should never be linted. - After every change to an
@Entity/@Field/@JsonSerializableannotation, rundart run build_runner build --delete-conflicting-outputsbefore considering the change done. - Never edit
*.entity.dartor*.g.dartby hand — change the annotation or write a custom override in*_config.dart.
Next Steps
- Complete LMS Example — the practices applied end-to-end
- Configuration — full
EntityConfigurationreference - Permissions — the
AuthorizeDSL in depth