Skip to main content

Why configure CRUD instead of writing endpoints?

A conventional backend answers that question once per model, by hand: an endpoint per operation, each one re-deciding who may call it, what counts as valid, and what to send back. The rules end up scattered across handlers, and the only way to learn how a model behaves is to read every method that touches it.

DartWay inverts this. The transport is written once, in the framework: DwCrudEndpoint serves getOne, getAll, getCount, saveModel, delete and a realtime subscription — for every model, with no per-model code. What you write instead is a declaration of the model's rules: a DwCrudConfig<T>.

One file per model, and that file is the model's server behaviour. Reviewing permissions means reading nine small files, not grepping an endpoint layer; and the client needs no new API surface — dw.repo.modelList<Note>() works the moment the config exists.

DwCrudConfig<T> — four slots

class DwCrudConfig<T extends TableRow> {
const DwCrudConfig({
required this.table, // ClubService.t — the generated table descriptor
this.getModelConfigs, // List<DwGetModelConfig<T>>? — reading one row
this.getListConfig, // DwGetModelListConfig<T>? — reading many
this.saveConfig, // DwSaveConfig<T>? — insert + update
this.deleteConfig, // DwDeleteConfig<T>? — delete
});
}

Everything is optional except the table. A model that is only ever read gets a read config and nothing else; a model that is never deleted gets no deleteConfig. Absence is the deny.

A complete config from example/ — the club price list, readable by any signed-in visitor, writable by the admin:

final clubServiceCrudConfig = DwCrudConfig<ClubService>(
table: ClubService.t,
getListConfig: DwGetModelListConfig(
accessFilter: (session) async => null,
),
saveConfig: DwSaveConfig<ClubService>(
allowSave: (session, saveContext) async => session.isClubAdmin,
validateSave: (session, saveContext) async {
final service = saveContext.currentModel;
if (service.title.trim().isEmpty) return 'Title is required';
if (service.durationMinutes <= 0) return 'Duration must be positive';
if (service.price < 0) return 'Price cannot be negative';
return null;
},
),
deleteConfig: DwDeleteConfig<ClubService>(
allowDelete: (session, model) => session.isClubAdmin,
),
);

session.isClubAdmin is not framework API — it is an extension the app writes over its own UserProfile.role. See access-and-roles.md.

Registration, and what happens without it

A config exists for the server only once it is in DwCore.init:

dw = DwCore.init<UserProfile>(
userProfileTable: UserProfile.t,
crudConfigurations: [
userProfileCrudConfig,
clubServiceCrudConfig,
// ...
],
// ...
);

The core indexes them by class name. Every endpoint method starts with the same lookup, and when it comes back empty the call is answered with DwApiResponse.notConfiguredisOk: false, and the error text Action not configured on server (getAll for ClubService).

That failure is the framework's security model, not an inconvenience: an unregistered model is not reachable at all. Access is something you grant, never something you forget to revoke. The typical first encounter is a new model whose screen shows the "not configured" message — the config was written and the registration line was not.

When a call fails

A denial and a failure are different answers. notConfigured and notAuthenticated above are decisions the server made on purpose; a failure is something that threw underneath — a table the database does not have yet, a column the schema never grew, a filter naming a field that does not exist.

Every method of the CRUD endpoint runs inside one error boundary, so a failure is never a bare HTTP 500. It comes back as isOk: false with the operation and the model in the text:

Unexpected error while handling the getAll request for ClubService

and the same failure — exception and the stack of the throw — is reported through dw.alerts (see error reporting). The model name is the part that matters: one endpoint serves every model in the application, so a report that says only which endpoint failed reads the same whichever list broke.

Nothing about the failure text describes the database. What threw goes to the operator, never to the client — the same rule the save path states below for DatabaseException.

A business rule saying no is a denial, and every config has a channel for it that is not a throw. In DwSaveConfig the rule hooks return the error text (validateSave and the ones inside the transaction — see the lifecycle below). In DwDtoActionConfig it is validateAction before the transaction opens, and throw DwActionRejection('This message was already deleted') from inside actionProcessing, where throwing is the only thing that rolls a transaction back. Either way the caller reads the text the rule was written in, word for word, and nothing reaches dw.alerts.

Refusing with a bare throw Exception('Not enough rights to delete this message') is the other thing. The text is lost on the way out, the caller is shown "Unexpected error while handling the saveModel request", and the operator is paged for a rule doing its job.

The difference travels to the client. A refused response carries isRefusal, and the Flutter side raises it as a DwRefusal — shown to the user in the rule's own words, and kept out of the alert channel. A failure carries no such mark and stays an error the app reports. Nothing in a config sets the flag by hand: refusing the documented way is what sets it. See error reporting.

Saving: one lifecycle for insert and update

DwSaveConfig<T> has no separate create and update paths. saveContext.isInsert tells the hooks which one is happening, and saveContext.initialModel holds the row as it was (null on insert).

The order, exactly as DwSaveConfig.save runs it:

#HookReturnsWhere
0resolveExistingRowForInsertFuture<T?>before the transaction, on inserts only
1allowSaveFuture<bool>requiredbefore the transaction
2validateSaveFuture<String?>before the transaction
3beforeSaveTransactionFuture<String?>inside the transaction
4insert / updateinside the transaction
5afterSaveTransactionFuture<String?>inside the transaction
6afterSaveTransformFuture<String?>after commit
7afterSaveSideEffectsFuture<void>after commit, not awaited

Step 0 is the odd one out and is described below; the rest run on every save.

All seven share one signature — (Session session, DwSaveContext<T> saveContext). There is no (initial, updated) pair anywhere; both models live in the context, along with currentUserId, transaction, extras for passing data between steps, and beforeUpdates / afterUpdates for extra models the client should refresh.

Rejecting. allowSave returning anything but true produces DwApiResponse.forbidden() — the fixed text Not enough permissions. The four String? hooks reject by returning the message, which reaches the user verbatim; returning null lets the save proceed. Rejection from inside the transaction rolls it back, including from afterSaveTransaction — a rule discovered after the write still undoes it.

afterSaveTransform is the exception, and the one place where rejecting and undoing come apart: it runs after the commit, so its message reaches the caller while the row stays written. That is the intended trade — deleting a committed row to report a failed email is worse — and it is why a hook there should be written so that a retry is harmless. Rejecting there does stop what follows: step 7 does not run and broadcastTo sends nothing, since announcing a change to other screens while answering the caller with an error would be the less coherent of the two.

Steps 6 and 7 differ by audience, not by timing. Both run after the commit; only step 6 is awaited, and only step 6 can answer. Nothing that happens in afterSaveSideEffects can reach the caller, its failure included: by the time it throws, the response has been built and says isOk. The throw is reported to DwAlerts with the model's name and the stack trace, so the operator hears it — and that is the whole of its audience.

So the question to ask is who needs to know. A push notification nobody misses if it is late → step 7. A verification code, a payment handed to a provider, anything whose failure changes what the user should do next → step 6, where the failure comes back as an error. The price is real and worth naming: step 6 makes the caller wait for whatever it calls out to.

Why three rejection points and not one. Steps 1–2 run before the transaction opens, against the database as it was a moment ago. A rule that guards a shared count — seats left, stock on hand, "only one active booking" — can be raced there: two concurrent saves both read four of five taken and both pass. Such a rule belongs in beforeSaveTransaction, which runs inside the transaction and can take a row lock. validateSave checks the model; beforeSaveTransaction checks the world around it.

The booking config in example/ is built on exactly that split — capacity and duplicate checks are deliberately not in validateSave:

beforeSaveTransaction: (session, saveContext) async {
if (!saveContext.isInsert) return null;
final booking = saveContext.currentModel;

final clubSession = await ClubSession.db.findById(
session,
booking.clubSessionId,
transaction: saveContext.transaction,
lockMode: LockMode.forUpdate, // SELECT ... FOR UPDATE, in the same call
);
if (clubSession == null) return 'Session not found';

final takenSpots = await SessionBooking.db.count(
session,
where: (t) => t.clubSessionId.equals(booking.clubSessionId) &
t.status.equals(BookingStatus.booked),
transaction: saveContext.transaction,
);
if (takenSpots >= clubSession.capacity) return 'No spots left for this session';

saveContext.currentModel = booking.copyWith(
status: BookingStatus.booked,
createdAt: DateTime.now(),
);
return null;
},

Without the lock those two counts are decorative: under READ COMMITTED both clients going for the last spot count the same four and both get in.

When the rule is about the row itself — a role, a consent flag, a balance, a cancellation marker — the race is on the initial model, which steps 1–2 read outside the transaction. Set lockInitialModelForUpdate: true: on updates the row is re-read under FOR UPDATE inside the transaction and allowSave / validateSave are evaluated against what was actually committed. It is opt-in so the default lifecycle is unchanged, and it does nothing on insert — there is no row to lock yet.

When the model has a natural key — one person's answer to one question, one membership of one user in one chat — the client thinks in that key, not in the id, and sooner or later it sends a create for a row that already exists: its list lagged, the person answered from a second device, the first response never arrived. Left alone, that write reaches the unique index and comes back a database error — an alert for you, and for the caller a failure it can do nothing about, since what it sent is exactly what it wants stored.

resolveExistingRowForInsert is step 0 for exactly this: on an insert it looks the row up by that key and returns the model to write instead — the incoming values carrying the stored id.

resolveExistingRowForInsert: (session, answer) async {
final stored = await UserQuizAnswer.db.findFirstRow(
session,
where: (t) =>
t.userProfileId.equals(answer.userProfileId) &
t.questionId.equals(answer.questionId),
);
return stored == null ? null : answer.copyWith(id: stored.id);
},

From there the save is an ordinary update: isInsert is false, initialModel is the stored row, and every hook after step 0 sees the save that is actually happening. Returning null leaves the insert an insert.

It runs before the transaction, so it does not settle a race between two simultaneous creates — both can find nothing and both insert, and the unique index answers the second one. Guard that in beforeSaveTransaction if it matters; step 0 is about the client that is simply behind, which is the case that actually happens.

A scope=serverOnly column is not overwritten by a client save, and there is nothing to switch on for that. Such a field does not exist on the client class, so it is never in the JSON a client sends, and the model the server deserialises carries null there — an update writing the whole row would blank the column, with no error and an isOk response. It is left out of the UPDATE instead, and the database keeps its value. (The outbound half is held too: the value is never sent to a client in the first place — see fields the client must never see.)

The rule is narrow on purpose: the column is skipped only when the incoming value is null and the stored row has one. A beforeSaveTransaction hook that computes a serverOnly value assigns it, and it is written like any other field. The one case that needs an opt-out is a hook meaning to clear such a field back to nullallowServerOnlyOverwrite: true, which then also lets an ordinary client save blank the column.

The model is not re-read from the database after the write, so from afterSaveTransaction onwards a serverOnly field on currentModel still holds what the client sent, even though the stored row kept its value. A hook that needs the stored one reads saveContext.initialModel.

Every read inside the transaction carries saveContext.transaction — that is the two in-transaction hooks, and every call they make. A model repository takes it as a named argument, so does dw.db(session) (see working with a model you only know as a type), and so do the framework's profile reads:

beforeSaveTransaction: (session, saveContext) async {
final author = await dw.currentUserProfile(session, transaction: saveContext.transaction);
...
},

Omitting it runs the read on its own connection. In production that works — it is merely blind to the uncommitted rows around it, which is what makes the omission easy to miss. The bill arrives in the test: under serverpod_test with database rollbacks enabled, which is the default, the proxy sees a call arriving outside the active transaction, calls it concurrent, and throws Concurrent database calls outside an already active transaction are not supported when database rollbacks are enabled. The config keeps working in production and can no longer be driven through save() by an integration test at all. example/ pins this in test/integration/dw_core_profile_transaction_test.dart.

Half of these reads need not happen at all: when the hook only needs to know who the caller is, session.signedInUserProfileId is synchronous and costs nothing. Read the profile row when you need something on it — a role, a balance, a consent flag.

What comes back. A successful save returns the persisted model plus updatedModels: beforeUpdates + the saved row + afterUpdates. The client applies that list to every open list and single-model provider, which is why the caller's own screens refresh without a refetch. Getting the change onto other users' screens is broadcastTo — see realtime.md.

A response may carry what the row does not. A freshly issued key, a one-time code, a receipt computed during the write: give the field !persist so it has a place on the model and no column behind it, carry the value between hooks in saveContext.extras, and assign it to saveContext.currentModel in afterSaveTransform — the response is built from that object, after the commit. Assign it there rather than earlier: a value merely left on the model survives only while the framework does not re-read the row, which is an internal detail to depend on. Mind that the response and broadcastTo are built from the same object, so anything assigned this way reaches every subscriber of the channels this config broadcasts to — a save that issues a secret must not broadcast.

Database errors never reach the client verbatim. A DatabaseException is reported through dw.alerts in full and answered with the flat text Database error during save. A raw exception carries table and constraint names, and a unique-violation would tell the caller whether a value already exists.

Reading: lists, and single rows by prototype

DwGetModelListConfig serves both getAll and getCount. Its parameters are accessFilter (required), allowAnonymous (default false — see access and roles), include for eager-loading relations, and defaultOrderByList:

getListConfig: DwGetModelListConfig(
accessFilter: (session) async => null,
include: NewsPost.include(authorProfile: UserProfile.include()),
defaultOrderByList: [Order(column: NewsPost.t.createdAt, orderDescending: true)],
),

A filter sent by the client is turned into a WHERE and ANDed with the access filter — the client narrows, it never widens. Ordering, limit and offset come from the client too, with defaultOrderByList as the fallback.

getModelConfigs is a list, and works differently: each entry declares a filterPrototype, and an incoming getOne request is matched against those prototypes by shape — field name, comparison type, negation — not by value. No matching prototype, no answer: notConfigured. That is why one model may have several read configs, each with its own filter shape, include and accessFilter. The framework's own config for the signed-in user's profile is the smallest example:

DwGetModelConfig<UserProfileClass>(
accessFilter: (session) async =>
_userInfoIdColumn.equals(_authenticatedUserId(session) ?? 0),
filterPrototype: DwBackendFilter.equalsPrototype(fieldName: 'id'),
include: _userProfileInclude,
)

Prototypes are built with dedicated constructors — .equalsPrototype(fieldName:), .andPrototype(children:), .orPrototype(children:); there is no bare DwBackendFilter(...) on the server.

The trap: dw.repo.model<T>(id: 5) on the client sends an equals-on-id filter, so it needs a config with equalsPrototype(fieldName: 'id'). None of the nine configs in example/ declares one — the example reads everything through lists — so adding a by-id screen means adding the read config first. DwGetModelConfig also takes createIfMissing, a callback that materialises the row when the query finds nothing (settings, a profile-adjacent singleton).

Deleting

deleteConfig: DwDeleteConfig<ClubService>(
allowDelete: (session, model) => session.isClubAdmin,
),

allowDelete is nominally optional but effectively required: without it the call answers notConfigured. afterDelete returns related rows that changed so the client can refresh them, and broadcastTo mirrors the save side.

Three behaviours worth knowing, all from DwDeleteConfig.delete:

  • A row that is already gone answers isOk: true with the warning Model not found, possibly deleted earlier — and that check runs before permissions.
  • A foreign-key violation is translated into Cannot delete model because other entities reference it rather than surfacing the database error.
  • There is no before-hook and no transaction. afterDelete runs once the row is already gone, with no transaction handle.

That last point decides a design question. A deletion that must atomically adjust a shared counter — free a seat, return stock — cannot be done here. Model it as a status change through DwSaveConfig instead, where a transaction and a lock exist. example/ does this: a booking is never deleted, it moves to BookingStatus.cancelled, and sessionBookingCrudConfig has no deleteConfig at all.

When you do need an endpoint

The configs cover what fits the shape "a client writes a model, the server rules over it". Some things do not: file upload and download, inbound webhooks, long-running processing, an operation that is not about a single row.

Two intermediate steps come first, and skipping them is the usual mistake:

  1. A DTO config. DwDtoActionConfig<DTO> runs an arbitrary transaction from a serializable model that has no table and returns the models it touched — an action shaped as a save. Its rules refuse through validateAction (before the transaction, return the error text) or DwActionRejection (thrown from inside it, rolls the transaction back), never through a bare exception — see when a call fails. DwDtoGetListConfig<DTO, Model> serves a list of projections built from real rows.
  2. A model for the event. If the operation is a fact worth recording — an auth attempt, a balance movement — write the fact as a row and put the logic in its save config. That is how DartWay's own auth works: the client saves a DwAuthRequest, and dwAuthRequestConfig drives the whole flow.

When a real endpoint is warranted, write it as an exception and say so in a comment: it is the one place where the "read the config to know the rules" guarantee does not hold. Return a DwApiResponse with its updatedModels filled in, and on the client feed it through dw.repo.processApiResponse so open lists stay in sync — otherwise your bespoke endpoint is the one operation the app's screens do not notice.