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