CRUD Configs
In DartWay, all server logic must go through CRUD configs.
This ensures consistency, predictability, and AI-friendly code generation.
π Unlike raw Serverpod, DartWay uses wrapper classes (DwSaveConfig, DwDeleteConfig, DwGetModelConfig, DwGetListConfig) where you provide callbacks for validation, processing, and side effects.
π All configs for a model are collected into a single DwCrudConfig<T>.
π¦ DwCrudConfig (entry point per model)β
Each model must have a single DwCrudConfig<T> that aggregates all related configs.
This is the entry point for AI and developers β all rules for a model are defined here.
final userProfileCrudConfig = DwCrudConfig<UserProfile>(
table: UserProfile.t,
getModelConfigs: [ ... ],
getListConfig: ...,
saveConfig: ...,
deleteConfig: ...,
);
tableβ required reference to the table.getModelConfigsβ may contain multiple configs for different filters/rules.getListConfigβ rules for lists.saveConfigβ rules for insert/update.deleteConfigβ rules for deletion.
π If a config is missing, the API will return DwApiResponse.notConfigured.
π¦ DwGetModelConfigβ
Purpose: fetch a single model instance by filter.
Allows multiple configs for the same model to support different access rules or projections.
Parametersβ
filterPrototypeβ defines which requests this config applies to. Itβs used to match incoming query filters with the correct config.includeβ optionalIncludeobject for eager loading relations.additionalEntitiesFetchFunctionβ function(session, model) => [relatedModels]to include extra related data in the response.createIfMissingβ function(session, filter) => modelthat creates an entity if not found.
Behaviorβ
- Matches request against
filterPrototypeto decide if config applies. - Fetches entity by filter.
- If not found β optionally calls
createIfMissing. - Returns wrapped response with the found (or created) entity and additional related models.
Exampleβ
final userProfileGetConfig = DwGetModelConfig<UserProfile>(
filterPrototype: DwBackendFilter(field: 'id'),
include: UserProfile.include(
address: Address.include(),
),
additionalEntitiesFetchFunction: (session, model) async {
return await session.db.find<BalanceEvent>(
where: (t) => t.userProfileId.equals(model.id),
);
},
createIfMissing: (session, filter) async {
return UserProfile(id: filter.value as int, name: 'Guest User');
},
);
π¨ DwGetListConfigβ
Purpose: fetch a list of models with optional filters, ordering, pagination, and related data.
Parametersβ
includeβ optionalIncludeobject for eager loading relations.additionalModelsFetchFunctionβ function(session, models) => [relatedModels]to include extra related data for the returned list.defaultOrderByListβ default ordering if no explicit order is provided in the query.
Behaviorβ
- Fetches models from DB with
whereClause,limit,offset. - Orders results with
defaultOrderByListif provided. - Wraps results in
DwModelWrapper. - Optionally fetches additional related models via
additionalModelsFetchFunction.
Exampleβ
final userProfileListConfig = DwGetListConfig<UserProfile>(
include: UserProfile.include(
address: Address.include(),
),
defaultOrderByList: [UserProfile.t.createdAt.desc()],
additionalModelsFetchFunction: (session, models) async {
return await session.db.find<BalanceEvent>(
where: (t) => t.userProfileId.inSet(models.map((u) => u.id)),
);
},
);
π© DwSaveConfigβ
Purpose: handle creation and update of models with permissions, validation, pre/post-processing, and side effects.
Parametersβ
allowSaveβ required permission check for both insert & update.validateSaveβ optional validation hook, returns error string or null.beforeSaveβ optional pre-processing inside transaction, can modify model and return extra updates.afterSaveβ optional post-processing inside transaction, can return additional updated models.afterSaveSideEffectsβ optional async side effects outside transaction (notifications, background tasks).
Behaviorβ
- Loads initial model (if update).
- Runs
allowSavecheck β must return true. - Runs
validateSavehook β may return error. - Runs inside transaction:
- Calls
beforeSave(optional) β modifies model & collects updates. - Inserts or updates the row.
- Calls
afterSave(optional) β collects extra updates.
- Calls
- Runs
afterSaveSideEffectsoutside transaction (fire-and-forget). - Returns
DwApiResponsewith saved model and all updated models wrapped.
Exampleβ
final userProfileSaveConfig = DwSaveConfig<UserProfile>(
allowSave: (session, initial, updated) async {
// Only allow if email is unique
final exists = await session.db.findFirstRow<UserProfile>(
where: (t) => t.email.equals(updated.email) & t.id.notEquals(updated.id),
);
return exists == null;
},
validateSave: (session, initial, updated) async {
if (updated.email.isEmpty) return 'Email cannot be empty';
return null;
},
beforeSave: (session, transaction, initial, updated) async {
// Normalize name before save
final processed = updated.copyWith(
name: updated.name.trim(),
);
return DwPreSaveResult(model: processed);
},
afterSave: (session, transaction, initial, saved, {beforeUpdates}) async {
// Return balance events as extra updates
final events = await session.db.find<BalanceEvent>(
where: (t) => t.userProfileId.equals(saved.id),
);
return events.map((e) => DwModelWrapper(object: e)).toList();
},
afterSaveSideEffects: (session, initial, saved, {beforeUpdates, afterUpdates}) async {
// Fire notification
await AppNotifications.sendProfileUpdated(session, saved);
},
);
π₯ DwDeleteConfigβ
Purpose: handle safe deletion of models with permission checks and post-delete actions.
Parametersβ
allowDeleteβ optional function(session, model) => boolto check if model can be deleted.afterDeleteβ optional function(session, model) => [relatedModels]to return additional updates after deletion.
Behaviorβ
- Loads model by id.
- If not found β returns ok=true with warning (already deleted).
- If no
allowDeleteprovided β returnsnotConfigured. - Runs
allowDeleteβ must return true. - Deletes row inside DB.
- If
DatabaseExceptionoccurs (e.g., FK constraints) β returns error.
- If
- Returns
DwApiResponsewithDwModelWrapper.deletedand optionally related models fromafterDelete.
Exampleβ
final userProfileDeleteConfig = DwDeleteConfig<UserProfile>(
allowDelete: (session, model) async {
// Prevent deletion if user still has balance
return model.balance <= 0;
},
afterDelete: (session, model) async {
// Clean up associated balance events
return await session.db.find<BalanceEvent>(
where: (t) => t.userProfileId.equals(model.id),
);
},
);
π¦ Workflowβ
- Create or update a model in
/models. - Generate code:
dart run serverpod generate - Create a
DwCrudConfig<T>in/crud. - Implement validation, permissions, pre/post-processing.
- Add side effects where needed (wrap updates in
DwModelWrapper). - Write tests in
/test.
β‘ Best Practicesβ
- Always use CRUD configs β never put logic directly in endpoints.
- One
DwCrudConfigper model β single entry point for all rules. - Keep configs small β validation, checks, and side effects only.
- Use
/domainfor pure logic (extensions). - Use
/appfor workflows (session-aware services). - Event models for transactional flows.
- Side effects go into
afterSave,afterDelete, or dedicatedsideEffects.
β Key Takeawaysβ
- CRUD configs (
DwCrudConfig,DwGetModelConfig, etc.) are the only allowed place for server logic. - Every model must have one
DwCrudConfigcollecting all rules. - Keep them predictable and small.
- Extend logic in
/domainor/app, but always trigger through CRUD. - Event models + CRUD configs = safe, traceable business flows.
- See also: Serverpod Endpoints Docs.