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.
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:
final courseApi = HttpEntityApi<Course>(
entityType: 'courses',
schemaType: 'lms.course',
pathBuilder: EndpointBuilder(prefix: 'courses'),
fromJson: Course.fromJson,
defaultSortField: 'name',
hasDrafts: false,
);| Parameter | Required | Purpose |
|---|---|---|
entityType | yes | Identifier matching EntityMetadata.identifier |
schemaType | yes | Schema-qualified name (e.g., 'lms.course') |
pathBuilder | yes | Builds REST URIs for each operation |
fromJson | yes | Factory: T Function(Map<String, dynamic>) |
defaultSortField | no | Server-side default sort column |
hasDrafts | no | Enables getDrafts / countDrafts (default false) |
EndpointBuilder
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 → findPositionOverride baseUrl to point at a non-default API origin:
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
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
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',
},
);| Parameter | Default | Purpose |
|---|---|---|
page | 0 | Zero-based page index |
pageSize | 20 | Number of items per page |
query | null | Free-text search; sent as search= |
sortField | null | Falls back to defaultSortField |
sortAscending | true | Sort direction |
filters | null | Discriminated-union JSON; sent as filters= |
count
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.
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
{
"schema_type": "cdx.query.filter.condition",
"field": "status",
"operator": "equals",
"value": "active"
}Group (AND / OR / NOT)
{
"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:
{
"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:
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:
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
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:
- Serialize via
entity.toJson(). - Strip the
idfield — the server assigns it. - Inject
created_by,updated_by,created_at,updated_atfrom the current auth state and clock (always UTC). - If
remarksis set, attach it as_audit_remarks.
After a 2xx response, the API announces a MutationType.create mutation so subscribed caches invalidate.
Updating Entities
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, andversion_number(the database trigger manages versioning). - Refreshes
updated_byandupdated_at. - Attaches
_audit_remarksifremarksis set.
After success, announces MutationType.update with the entity ID.
Deleting Entities
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:
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:
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:
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.
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
bodyas JSON. - Fan out the
announcesset 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
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:
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:
{
"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.
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:
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
- Search and Filters — operator vocabulary and filter UI
- Building UI — list/detail/workspace surfaces over the API
- Drafts and Versioning —
VersionedEndpointBuilderand the draft workflow - Custom Services — registering services that consume the cache and mutation streams