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.