Skip to content

CRUD Operations

This guide covers EntityApi<T>, the HttpEntityApi default implementation, the discriminated-union filter format, the cache-and-mutation observer pattern, and how to extend the API with custom mutation endpoints.

EntityApi Contract

EntityApi<T> is the abstract API contract every entity API satisfies. It is a thin, transport-agnostic surface — a different transport (HTTP, gRPC, in-memory) can be plugged in by subclassing it.

dart
abstract class EntityApi<T extends EntityBase> {
  final String entityType;
  final String schemaType;
  final String? defaultSortField;
  final bool hasDrafts;

  Future<T?> getOne(String id);

  Future<List<T>> getMany({
    int page = 0,
    int pageSize = 20,
    String? query,
    String? sortField,
    bool sortAscending = true,
    Map<String, dynamic>? filters,
  });

  Future<T> create(T entity, {String? remarks});
  Future<T> update(String id, T entity, {String? remarks});
  Future<bool> delete(String id);

  Future<int> count({String? query, Map<String, dynamic>? filters});
  Future<int?> findPosition(String entityId, { /* ... */ });

  // Drafts (only meaningful when hasDrafts: true)
  Future<List<T>> getDrafts({ /* same params as getMany */ });
  Future<int> countDrafts({String? query, Map<String, dynamic>? filters});

  T fromJson(Map<String, dynamic> json);
}

The filters parameter accepts the discriminated-union JSON described below. It must be JSON-serializable because HttpEntityApi JSON-encodes it into the filters query parameter.

Setting Up HttpEntityApi

HttpEntityApi<T> provides the standard REST implementation:

dart
final courseApi = HttpEntityApi<Course>(
  entityType: 'courses',
  schemaType: 'lms.course',
  pathBuilder: EndpointBuilder(prefix: 'courses'),
  fromJson: Course.fromJson,
  defaultSortField: 'name',
  hasDrafts: false,
);
ParameterRequiredPurpose
entityTypeyesIdentifier matching EntityMetadata.identifier
schemaTypeyesSchema-qualified name (e.g., 'lms.course')
pathBuilderyesBuilds REST URIs for each operation
fromJsonyesFactory: T Function(Map<String, dynamic>)
defaultSortFieldnoServer-side default sort column
hasDraftsnoEnables getDrafts / countDrafts (default false)

EndpointBuilder

dart
final builder = EndpointBuilder(prefix: 'courses');

// Standard endpoints:
// GET    {baseUrl}/courses                    → getMany
// GET    {baseUrl}/courses/{id}               → getOne
// POST   {baseUrl}/courses                    → create
// PUT    {baseUrl}/courses/{id}               → update
// DELETE {baseUrl}/courses/{id}               → delete
// GET    {baseUrl}/courses/count              → count
// GET    {baseUrl}/courses/{id}/position      → findPosition

Override baseUrl to point at a non-default API origin:

dart
EndpointBuilder(
  prefix: 'courses',
  baseUrl: 'https://api.example.com/v2',
);

For draft- and version-aware entities, use VersionedEndpointBuilder which adds endpoints for drafts, versions, audits, and activate/deactivate. See Drafts and Versioning.

Reading Entities

getOne

dart
final course = await courseApi.getOne('course-123');
if (course == null) {
  // 404 — caller decides how to surface "not found"
}

getOne returns null for HTTP 404 and throws an Exception for other non-2xx responses (with the server's error message extracted when available).

getMany

dart
final courses = await courseApi.getMany(
  page: 0,
  pageSize: 20,
  query: 'flutter',
  sortField: 'name',
  sortAscending: true,
  filters: {
    'schema_type': 'cdx.query.filter.condition',
    'field': 'level',
    'operator': 'equals',
    'value': 'advanced',
  },
);
ParameterDefaultPurpose
page0Zero-based page index
pageSize20Number of items per page
querynullFree-text search; sent as search=
sortFieldnullFalls back to defaultSortField
sortAscendingtrueSort direction
filtersnullDiscriminated-union JSON; sent as filters=

count

dart
final total = await courseApi.count();
final filtered = await courseApi.count(
  query: 'flutter',
  filters: { /* same shape as getMany */ },
);

findPosition

Find where a specific entity lives in a sorted, filtered list. Used by the list controller to scroll/select a freshly-saved entity.

dart
final position = await courseApi.findPosition(
  'course-123',
  query: 'flutter',
  filters: { /* ... */ },
  sortField: 'name',
  sortAscending: true,
);

if (position != null) {
  final page = position ~/ pageSize;
}

sortField and sortAscending must match the current list view's sort, otherwise the position will be wrong.

Filter Format

filters is a JSON tree built from two node types: conditions (leaf) and groups (composite). Both serialize via Filter.toJson() from cdx_query.

Single condition

json
{
  "schema_type": "cdx.query.filter.condition",
  "field": "status",
  "operator": "equals",
  "value": "active"
}

Group (AND / OR / NOT)

json
{
  "schema_type": "cdx.query.filter.group",
  "op": "and",
  "children": [
    {
      "schema_type": "cdx.query.filter.condition",
      "field": "level",
      "operator": "equals",
      "value": "beginner"
    },
    {
      "schema_type": "cdx.query.filter.condition",
      "field": "status",
      "operator": "inList",
      "value": ["draft", "published"]
    }
  ]
}

op is one of and, or, not. children is the list of nested filters.

Range value

between takes a {from, to} object:

json
{
  "schema_type": "cdx.query.filter.condition",
  "field": "duration_minutes",
  "operator": "between",
  "value": { "from": 60, "to": 120 }
}

Building filters in Dart

You almost never hand-write the JSON. Use the typed constructors from cdx_query:

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

final filter = filters.FilterGroup(
  op: filters.LogicalOp.and,
  children: [
    filters.FilterCondition(
      field: 'level',
      operator: filters.FilterOps.equals,
      value: filters.FilterValue.single('beginner'),
    ),
    filters.FilterCondition(
      field: 'status',
      operator: filters.FilterOps.inList,
      value: filters.FilterValue.list(['draft', 'published']),
    ),
  ],
);

final courses = await courseApi.getMany(filters: filter.toJson());

Or use the and / or / not helpers:

dart
final filter = filters.and([
  filters.FilterCondition(
    field: 'level',
    operator: filters.FilterOps.equals,
    value: filters.FilterValue.single('beginner'),
  ),
  filters.not(filters.FilterCondition(
    field: 'status',
    operator: filters.FilterOps.equals,
    value: filters.FilterValue.single('archived'),
  )),
]);

For the full operator vocabulary and value shapes, see Search and Filters.

Creating Entities

dart
final draft = Course(
  id: '',  // Server assigns
  name: 'Introduction to Flutter',
  description: 'Learn Flutter basics',
  level: 'beginner',
  status: 'draft',
  durationMinutes: 120,
);

final created = await courseApi.create(
  draft,
  remarks: 'Initial course creation',
);

HttpEntityApi.create calls the protected toCreateJson hook before posting:

  1. Serialize via entity.toJson().
  2. Strip the id field — the server assigns it.
  3. Inject created_by, updated_by, created_at, updated_at from the current auth state and clock (always UTC).
  4. If remarks is set, attach it as _audit_remarks.

After a 2xx response, the API announces a MutationType.create mutation so subscribed caches invalidate.

Updating Entities

dart
final updated = await courseApi.update(
  'course-123',
  existingCourse.copyWith(status: 'published'),
  remarks: 'Published after review',
);

toUpdateJson differs from toCreateJson:

  • Strips id, created_at, created_by, and version_number (the database trigger manages versioning).
  • Refreshes updated_by and updated_at.
  • Attaches _audit_remarks if remarks is set.

After success, announces MutationType.update with the entity ID.

Deleting Entities

dart
final ok = await courseApi.delete('course-123');

Returns true for 200/204 responses. On success, announces MutationType.delete.

Cache-and-Mutation Pattern

HttpEntityApi ships with an opinionated stale-while-revalidate cache backed by QueryCacheService. Every read goes through cachedGet, every write announces a Mutation, and reads that subscribed to that mutation are invalidated automatically.

Reads register their invalidation rules

The default getMany invalidates whenever any create / update / delete on this entity type happens:

dart
return cachedGet<List<T>>(
  operation: StandardCacheOperations.list,
  params: { /* page, sort, filters, ... */ },
  fetch: () => _rawGetMany(/* ... */),
  fallback: <T>[],
  invalidateWhen: {
    Mutation(entityType, MutationType.create),
    Mutation(entityType, MutationType.update),
    Mutation(entityType, MutationType.delete),
  },
);

getOne(id) only invalidates when that ID is mutated:

dart
invalidateWhen: {
  Mutation(entityType, MutationType.update, entityId: id),
  Mutation(entityType, MutationType.delete, entityId: id),
},

Writes announce mutations

The standard create, update, and delete already announce. For custom endpoints, see Custom Mutation Endpoints.

Cross-entity invalidation

When a write to entity A should invalidate a read of entity B, declare it explicitly. For example, an API that mutates checklist_usage_instructions and the parent checklist_usages row in the same call:

dart
Set<Mutation> _instructionMutation(String usageId) => {
  Mutation('checklist_usage_instructions', MutationType.update),
  Mutation('checklist_usages', MutationType.update, entityId: usageId),
};

Future<Map<String, dynamic>> submitInstructionResponse(String id, ...) =>
    post(
      '$id/submit_instruction_response',
      body: {/* ... */},
      announces: _instructionMutation(id),
    );

Never fix invalidation gaps at the read site — fix them at the source.

Custom Mutation Endpoints

HttpEntityApi exposes protected helpers for non-standard endpoints. They share the same announce semantics as built-in CRUD.

dart
class ChecklistApi extends HttpEntityApi<Checklist> {
  ChecklistApi()
      : super(
          entityType: 'checklists',
          schemaType: 'lms.checklist',
          pathBuilder: EndpointBuilder(prefix: 'checklists'),
          fromJson: Checklist.fromJson,
        );

  Set<Mutation> _selfMutation(String id) =>
      {Mutation(entityType, MutationType.update, entityId: id)};

  Future<Map<String, dynamic>> submit(String id, {String? remarks}) =>
      post(
        '$id/submit',
        body: {if (remarks != null) 'remarks': remarks},
        announces: _selfMutation(id),
      );

  Future<Map<String, dynamic>> archive(String id) =>
      put('$id/archive', announces: _selfMutation(id));
}

Available verbs: post, put, patch, and the lower-level mutate({uri, method, body, announces}) for delete-by-other-key or pre-built Uris. All of them:

  • Send body as JSON.
  • Fan out the announces set on a 2xx response.
  • Throw with the server's error message on non-2xx (no announce).
  • Skip announce on network errors (the server never committed).

For non-HTTP fan-out (SSE handlers, batch reconciliation), use announceAll(mutations) directly.

Bulk Import

dart
final result = await courseApi.bulkImport(
  newCourses,
  conflictKey: 'code',
  conflictStrategy: ConflictStrategy.update,
);

print('Created: ${result.created.length}');
print('Updated: ${result.updated.length}');
print('Failed:  ${result.failed.length}');

bulkImport posts to POST /{entity}/import. With a conflictKey, it operates as upsert; without it, it operates as create-only. After success it announces MutationType.create so list views refresh.

Caching Policy

Override on a subclass to change the stale window or skip caching for specific operations:

dart
class CourseApi extends HttpEntityApi<Course> {
  // ...

  @override
  CachingPolicy get cachingPolicy => const CachingPolicy(
        staleDuration: Duration(minutes: 3),
        excludedOperations: {'analytics_distribution'},
      );

  @override
  CacheKeyBuilder get keyBuilder => CacheKeyBuilder('courses');
}

CachingPolicy.none (staleDuration: Duration.zero) disables caching entirely.

Error Handling

Every non-2xx response throws an Exception with the message extracted from the server response body, falling back to a default. The expected error envelope:

json
{
  "success": false,
  "message": "Course not found",
  "error": {
    "code": "ENTITY_NOT_FOUND",
    "message": "No course exists with ID course-123"
  }
}

When error.code is present, the formatted message includes it for debugging.

In application code, route the exception through ErrorResolver.resolve(e).message and showApiErrorDialog rather than displaying the raw exception string.

dart
try {
  final course = await courseApi.getOne(id);
  // ...
} catch (e, st) {
  if (context.mounted) {
    showApiErrorDialog(
      context,
      title: 'Failed to load course',
      message: ErrorResolver.resolve(e).message,
      onRetry: () => loadCourse(id),
    );
  }
}

Draft Operations

When hasDrafts: true, the API exposes an additional pair of methods:

dart
final certApi = HttpEntityApi<Certification>(
  entityType: 'certifications',
  schemaType: 'lms.certification',
  pathBuilder: VersionedEndpointBuilder(prefix: 'certifications'),
  fromJson: Certification.fromJson,
  hasDrafts: true,
);

final drafts = await certApi.getDrafts(page: 0, pageSize: 20);
final draftCount = await certApi.countDrafts();

Both are cached and invalidate on any create/update for this entity type. See Drafts and Versioning for the full lifecycle.

Next Steps