Skip to content

Custom Services ​

The Vyuh Entity System includes a pluggable service architecture that lets you add cross-cutting functionality -- real-time updates, caching, localization, e-signatures, and more -- without modifying entity configurations. This guide covers the built-in services and shows how to create your own.

EntityService Base Class ​

Every service extends EntityService, which defines three lifecycle methods:

dart
abstract class EntityService {
  /// Unique name for logging and debugging
  String get name;

  /// Called when the EntitySystemPlugin initializes
  Future<void> initialize();

  /// Called when the EntitySystemPlugin is disposed
  void dispose();
}

The plugin manages the full lifecycle -- you implement initialize() for setup and dispose() for cleanup.

EntityServicesContainer ​

Services are stored in EntityServicesContainer, which provides type-safe registration and retrieval:

dart
class EntityServicesContainer {
  /// Register a service (type-safe)
  void register<T extends EntityService>(T service);

  /// Get a service by type (throws if not found)
  T get<T extends EntityService>();

  /// Get a service by type (returns null if not found)
  T? tryGet<T extends EntityService>();

  /// Check if a service is registered
  bool has<T extends EntityService>();
}

Access the container through the plugin:

dart
final services = vyuh.entity?.services;

// Type-safe retrieval
final cache = services?.get<EntityNameCacheService>();

// Safe retrieval (returns null if not registered)
final realtime = services?.tryGet<EntityRealtimeService>();

Convenience Extensions ​

EntityServicesAccess provides named getters for commonly used services:

dart
extension EntityServicesAccess on EntityServicesContainer {
  EntityLocalizationService get localization;
  EntityNameCacheService get cache;
  EntityHelpService get help;
  SearchSyncService get searchSync;
  SignatureVerificationService get verification;
  EntityLifecycleService? get lifecycle;  // nullable — may not be registered
  QueryCacheService get queryCache;
}

Usage:

dart
final cacheService = vyuh.entity?.services.cache;
final queryCache   = vyuh.entity?.services.queryCache;
final realtime     = vyuh.entity?.services.tryGet<EntityRealtimeService>();

Permission checks live on the Authorize infrastructure (vyuh.entity?.authorizationProvider), not on a dedicated entity service. See Permissions.

Built-In Services ​

The entity system registers these services automatically during plugin initialization:

ServicePurposeRegistered
EntityLocalizationServicei18n for entity names, fields, actionsAlways
EntityNameCacheServiceResolve entity IDs to display namesAlways
EntityHelpServiceContext-sensitive help contentAlways
SearchSyncServiceCommand palette search integrationAlways
EntityRealtimeServiceReal-time entity update subscriptionsAlways
QueryCacheServiceStale-while-revalidate query cachingAlways
SignatureVerificationServiceE-signature verification dialogsOnly when a SignatureVerificationProvider is configured
EntityLifecycleServicePer-entity lifecycle policy resolutionApp-provided (e.g., flutter_lms)

When To Use The Service Container vs Constructor Injection ​

The service container is a deliberately small surface — it exists for cross-cutting concerns that the entity infrastructure itself needs to discover at runtime: caching, real-time updates, name resolution, signature verification, lifecycle policy. It is not a general-purpose DI container.

For everything else, prefer constructor injection:

dart
// GOOD — explicit dependency wired by the feature
class CourseApi extends HttpEntityApi<Course> {
  final EnrollmentRepository enrollmentRepo;

  CourseApi({required this.enrollmentRepo})
      : super(/* ... */);
}

// BAD — service container as a backdoor for what should be a constructor parameter
class CourseApi extends HttpEntityApi<Course> {
  EnrollmentRepository get _repo =>
      vyuh.entity!.services.get<EnrollmentRepository>();
}

A new EntityService is justified when:

  • The behavior is needed by the entity infrastructure itself (cache invalidation, name resolution, signature dialogs).
  • It owns a cross-entity background subscription that needs to live for the app's lifetime.
  • It wraps a transport (Realtime SSE, search index sync) that several entities consume.

For domain logic, helpers, formatters, and entity-specific utilities, instantiate them in the feature's setup code and pass them through constructors.

EntityRealtimeService ​

Provides real-time entity update subscriptions using a pluggable strategy pattern. The default strategy is polling, which works with any REST backend.

Architecture ​

RealtimeConfig ​

Configure what to monitor and how:

dart
import 'package:cdx_query/cdx_query.dart' as filters;

final config = RealtimeConfig(
  entityType: 'courses',
  filter: const filters.FilterCondition(
    field: 'status',
    operator: filters.FilterOps.equals,
    value: filters.FilterValue.single('published'),
  ),
  onUpdate: () => _refreshCourseList(),
  refreshInterval: const Duration(seconds: 10),
  pauseWhenInactive: true,
);
PropertyDefaultPurpose
entityTypeRequiredEntity type to monitor
onUpdateRequiredCallback when updates are detected
filternullNarrow monitoring scope
onEventnullPer-event callback (not debounced)
refreshInterval10 secondsPolling interval
pauseWhenInactivetruePause when app is backgrounded

Subscribe and Unsubscribe ​

dart
final realtimeService = vyuh.entity!.services.get<EntityRealtimeService>();

// Subscribe -- returns an ID for later management
final subscriptionId = realtimeService.subscribe(
  RealtimeConfig(
    entityType: 'courses',
    onUpdate: () => _loadCourses(),
    refreshInterval: Duration(seconds: 15),
  ),
);

// Pause temporarily (e.g., user navigated away)
realtimeService.pauseSubscription(subscriptionId);

// Resume when user returns
realtimeService.resumeSubscription(subscriptionId);

// Unsubscribe when done
realtimeService.unsubscribe(subscriptionId);

Polling Strategy ​

PollingRealtimeStrategy is the default. It uses Timer.periodic and respects the app lifecycle -- when the app is backgrounded and pauseWhenInactive is true, polling stops. When the app resumes, it immediately triggers an update to fetch fresh data.

Custom Strategy ​

Implement RealtimeStrategy for SSE, WebSocket, or database subscriptions:

dart
class SseRealtimeStrategy implements RealtimeStrategy {
  @override
  String get name => 'sse';

  @override
  Future<void> initialize() async {
    // Connect to SSE endpoint
  }

  @override
  RealtimeSubscription createSubscription(RealtimeConfig config) {
    // Return an SSE-backed subscription
    return SseSubscription(config);
  }

  @override
  void dispose() {
    // Close SSE connections
  }
}

// Use it:
await plugin.registerService<EntityRealtimeService>(
  EntityRealtimeService(strategy: SseRealtimeStrategy()),
);

EntityNameCacheService ​

Resolves entity IDs to display names with in-memory caching and request deduplication.

How It Works ​

When a UI widget needs to display a referenced entity's name (e.g., showing the trainer's name on a course card), it calls the name cache service instead of making a full API request:

dart
final cacheService = vyuh.entity!.services.cache;

// Type-safe lookup
final trainerInfo = await cacheService.get<Trainer>('trainer-123');
print(trainerInfo?.name); // "Dr. Smith"

// Identifier-based lookup
final info = await cacheService.getByIdentifier('trainers', 'trainer-123');

Caching Behavior ​

  • First call: Fetches the entity via the configured EntityApi.getOne(), caches the result as an EntityInfo(id, name)
  • Subsequent calls: Returns from cache instantly
  • In-flight deduplication: Multiple simultaneous requests for the same entity share a single API call
  • Manual invalidation: Clear specific entries or the entire cache
dart
// Invalidate a specific entity
cacheService.invalidate<Trainer>('trainer-123');

// Invalidate all entries for an entity type
cacheService.invalidate<Trainer>();

// Invalidate everything
cacheService.invalidateAll();

// Cache statistics
print(cacheService.cacheSize);              // Total cached entries
print(cacheService.cacheSizeByIdentifier);  // Per-type breakdown

EntityLocalizationService ​

Provides localized strings for entity names, field labels, action titles, hints, and error messages.

Registering Localization Data ​

dart
final locService = vyuh.entity!.services.localization;

locService.register<Course>(
  EntityLocalizationData(
    locales: {
      'en': EntityLocaleData(
        entityName: 'Course',
        entityPluralName: 'Courses',
        fieldLabels: {
          'name': 'Course Name',
          'level': 'Difficulty Level',
          'durationMinutes': 'Duration (minutes)',
        },
        fieldHints: {
          'name': 'Enter the course title',
          'description': 'Provide a brief course description',
        },
        actionLabels: {
          'publish': 'Publish Course',
          'archive': 'Archive Course',
        },
      ),
      'es': EntityLocaleData(
        entityName: 'Curso',
        entityPluralName: 'Cursos',
        fieldLabels: {
          'name': 'Nombre del Curso',
          'level': 'Nivel de Dificultad',
        },
        actionLabels: {
          'publish': 'Publicar Curso',
          'archive': 'Archivar Curso',
        },
      ),
    },
  ),
);

Using Localized Strings ​

dart
// Entity names
final name = locService.getEntityName<Course>();         // "Course" or "Curso"
final plural = locService.getEntityPluralName<Course>();  // "Courses" or "Cursos"

// Field labels and hints
final label = locService.getFieldLabel<Course>('name');   // "Course Name"
final hint = locService.getFieldHint<Course>('name');     // "Enter the course title"

// Action labels
final actionLabel = locService.getActionLabel<Course>('publish'); // "Publish Course"

// Error messages with parameter substitution
final error = locService.getFieldError<Course>(
  'name', 'minLength', null, {'min': 3},
);

// Locale management
locService.setLocale(Locale('es'));
print(locService.currentLocale); // Locale('es')

EntityMetadata Localization ​

EntityMetadata also supports resolver functions for i18n:

dart
final metadata = EntityMetadata(
  identifier: 'courses',
  name: 'Course',
  nameResolver: () => t.strings.lms.entities.course.singular,
  pluralName: 'Courses',
  pluralNameResolver: () => t.strings.lms.entities.course.plural,
  // ...
);

The name and pluralName fields serve as fallbacks when resolvers are not provided.

SignatureVerificationService ​

Handles electronic signature workflows for regulated environments. Requires a SignatureVerificationProvider implementation.

Provider Interface ​

dart
abstract class SignatureVerificationProvider {
  /// Show the signature verification dialog
  Future<SignatureVerificationResult?> showSignatureVerificationDialog({
    required BuildContext context,
    String description,
    SignatureEntity? entity,
    SignatureEntityResolver? entityResolver,
    required SignatureAction action,
    bool requireSignature = true,
    bool showRemarksField = false,
    bool requireRemarks = true,
    SignatureRole? role,
  });

  /// Verify user credentials (email + password)
  Future<VerifiedIdentity?> verifyCredentials({
    required String email,
    required String password,
  });

  bool get supportsSignatureVerification;
}

Using the Service ​

dart
final sigService = vyuh.entity!.services.verification;

final result = await sigService.verify(
  context: context,
  action: (
    type: 'approve_certification',
    title: 'Approve Certification for ${participant.name}',
    handler: (result) async {
      await certificationApi.approve(
        certId,
        signatureId: result.signatureId,
        remarks: result.remarks,
      );
    },
  ),
  entity: (
    id: certId,
    name: certification.name,
    type: 'certifications',
    typeLabel: 'Certification',
  ),
  requireSignature: true,
  showRemarksField: true,
);

if (result == null) return; // Cancelled or failed
// Action already executed inside the dialog

Signature Roles ​

dart
enum SignatureRole {
  doer,       // Person who performed the action
  witness,    // Independent observer
  reviewer,   // Qualified reviewer/approver
}

QueryCacheService ​

Central facade for stale-while-revalidate query caching, backed by cached_query_flutter.

Creating Cached Queries ​

dart
final queryCache = vyuh.entity!.services.queryCache;

// Simple cached query
final query = queryCache.createQuery<Course?>(
  key: 'courses:byId:course-123',
  queryFn: () => courseApi.getOne('course-123'),
  staleDuration: Duration(minutes: 5),
);

// Observable query for MobX integration
final courseQuery = queryCache.observe<Course?>(
  key: 'courses:byId:course-123',
  queryFn: () => courseApi.getOne('course-123'),
);

// Use in Observer widget:
Observer(builder: (_) {
  final state = courseQuery.value;
  return switch (state) {
    QueryLoading() => CircularProgressIndicator(),
    QuerySuccess(:final data) => Text(data?.name ?? ''),
    QueryError(:final error) => Text('Error: $error'),
    _ => SizedBox.shrink(),
  };
});

CacheKeyBuilder ​

Generates hierarchical cache keys for consistent invalidation:

dart
final keyBuilder = CacheKeyBuilder('courses');

keyBuilder.key();                        // "courses:"          (entity prefix)
keyBuilder.key('list');                  // "courses:list:"      (operation prefix)
keyBuilder.key('byId', 'abc');           // "courses:byId:abc"   (specific key)
keyBuilder.key('list', {'page': 0});     // "courses:list:{...}" (parameterized)

CachingPolicy ​

Controls caching behavior per API:

dart
class CachingPolicy {
  final Duration staleDuration;          // Default: 5 minutes
  final Set<String> excludedOperations;  // Operations to skip caching

  static const none = CachingPolicy(staleDuration: Duration.zero);
}

Override in your HttpEntityApi subclass:

dart
class CourseApi extends HttpEntityApi<Course> {
  @override
  CachingPolicy get cachingPolicy => CachingPolicy(
    staleDuration: Duration(minutes: 3),
    excludedOperations: {'analytics_distribution'},
  );
}

Targeted Invalidation ​

dart
// Invalidate all queries for an entity type
queryCache.invalidateEntity('courses');

// Invalidate a specific entity by ID
queryCache.invalidateById('courses', 'course-123');

// Invalidate a specific operation
queryCache.invalidateOperation('courses', 'list');

// Listen for invalidation events (auto-refresh)
queryCache.invalidations.listen((event) {
  print('Invalidated: ${event.entityType} ${event.entityId ?? "all"}');
});

Mutation-Based Invalidation ​

The Mutation and subscription system enables automatic cache invalidation:

dart
// Subscribe: invalidate list cache when any course mutation occurs
queryCache.subscribe(
  keyBuilder.key('list'),
  { Mutation.any('courses') },
);

// Announce a mutation after creating a course
queryCache.announce(Mutation('courses', MutationType.create));
// All subscribed caches matching this mutation are automatically invalidated

EntityHelpService ​

Provides context-sensitive help content loaded from JSON assets or metadata callbacks:

dart
final helpService = vyuh.entity!.services.help;

// Get entity-level help
final entityHelp = await helpService.getEntityHelp<Course>();
print(entityHelp?.title);
print(entityHelp?.description);

// Get field-level help
final fieldHelp = await helpService.getFieldHelp<Course>('level');
print(fieldHelp?.tips);

// Get action help with steps
final actionHelp = await helpService.getActionHelp<Course>('publish');
print(actionHelp?.steps);
print(actionHelp?.warnings);

// Search across all help content
final results = await helpService.searchHelp<Course>('duration');

Register help content from JSON assets:

dart
helpService.registerAssetHelp(
  'courses',
  'assets/help/courses.json',
  'my_lms_package',
);

Creating a Custom Service ​

Step 1: Implement EntityService ​

dart
class EnrollmentCountService extends EntityService {
  final Map<String, int> _counts = {};
  String? _realtimeSubscriptionId;

  @override
  String get name => 'enrollment_count_service';

  @override
  Future<void> initialize() async {
    // Subscribe to real-time enrollment updates
    final realtime = vyuh.entity?.services.get<EntityRealtimeService>();
    _realtimeSubscriptionId = realtime?.subscribe(
      RealtimeConfig(
        entityType: 'enrollments',
        onUpdate: _refreshCounts,
        refreshInterval: Duration(seconds: 30),
      ),
    );
  }

  @override
  void dispose() {
    if (_realtimeSubscriptionId != null) {
      vyuh.entity?.services
          .get<EntityRealtimeService>()
          .unsubscribe(_realtimeSubscriptionId!);
    }
    _counts.clear();
  }

  /// Get enrollment count for a course
  Future<int> getCount(String courseId) async {
    if (_counts.containsKey(courseId)) {
      return _counts[courseId]!;
    }
    return _fetchCount(courseId);
  }

  Future<int> _fetchCount(String courseId) async {
    // Fetch from API
    final count = await _api.getEnrollmentCount(courseId);
    _counts[courseId] = count;
    return count;
  }

  void _refreshCounts() {
    _counts.clear(); // Force re-fetch on next access
  }
}

Step 2: Register with EntityServiceRegistration ​

Register via EntityExtensionDescriptor so the service initializes with the entity system:

dart
EntityExtensionDescriptor(
  entities: [courseConfig, participantConfig],
  services: [
    EntityServiceRegistration<EnrollmentCountService>(
      EnrollmentCountService(),
    ),
  ],
)

Or register directly on the plugin:

dart
await vyuh.entity!.registerService<EnrollmentCountService>(
  EnrollmentCountService(),
);

Step 3: Access the Service ​

dart
final enrollmentService = vyuh.entity!.services.get<EnrollmentCountService>();
final count = await enrollmentService.getCount('course-123');

LMS Example: Real-Time Enrollment Counts ​

A practical example combining EntityRealtimeService, QueryCacheService, and a custom service:

dart
class EnrollmentDashboardService extends EntityService {
  final Map<String, Observable<int>> _courseCounts = {};

  @override
  String get name => 'enrollment_dashboard_service';

  @override
  Future<void> initialize() async {
    // Listen for enrollment invalidation events
    final queryCache = vyuh.entity!.services.queryCache;
    queryCache.invalidations
        .where((event) => event.entityType == 'enrollments')
        .listen((_) => _refreshAll());
  }

  @override
  void dispose() {
    _courseCounts.clear();
  }

  /// Get an observable enrollment count for a course
  Observable<int> observeCount(String courseId) {
    return _courseCounts.putIfAbsent(
      courseId,
      () => Observable(0),
    );
  }

  void _refreshAll() {
    for (final entry in _courseCounts.entries) {
      _fetchAndUpdate(entry.key, entry.value);
    }
  }

  Future<void> _fetchAndUpdate(
    String courseId,
    Observable<int> observable,
  ) async {
    final count = await _fetchFromApi(courseId);
    runInAction(() => observable.value = count);
  }

  Future<int> _fetchFromApi(String courseId) async {
    // API call to get enrollment count
    return 42;
  }
}

Use in a widget:

dart
class EnrollmentCountBadge extends StatelessWidget {
  final String courseId;

  const EnrollmentCountBadge({required this.courseId});

  @override
  Widget build(BuildContext context) {
    final service = vyuh.entity!.services.get<EnrollmentDashboardService>();
    final count = service.observeCount(courseId);

    return Observer(
      builder: (_) => Badge.count(
        count: count.value,
        child: Icon(Icons.people),
      ),
    );
  }
}

Service Lifecycle ​

Core services are registered first during init(). Feature-specific services registered through EntityExtensionDescriptor are initialized during extension loading.

Next Steps ​

  • Permissions -- deep dive into Authorize and AuthorizationProvider
  • Data Flow -- how query caching integrates with data fetching
  • Relationships -- using EntityNameCacheService to resolve references