CRUD conventions
A resource that a Bsft application exposes for editing does so through the same five endpoints,
with the same shapes and the same failures, every time. The point is not tidiness: a generic client
— a table bound to a resource, a form that saves it, a confirm-delete dialog — can only be written
once if it can predict the server. This page is that prediction, and
samples/Bsft.Sample.Inventory implements it end to end for the products resource.
The five endpoints
{resource} is the plural resource name; the controller route is api/[controller], so
ProductsController serves api/products. Matching is case-insensitive, but the [controller]
token keeps the controller's own casing, so the Location header a create returns reads
/api/Products/{id}. A client must treat the returned URL as opaque rather than string-compare it
against the path it requested.
| Operation | Route | Success | Response body |
|---|---|---|---|
| List | GET /api/{resource} |
200 | PagedResult<TItem> |
| Read | GET /api/{resource}/{id} |
200 | TItem |
| Create | POST /api/{resource} |
201 + Location |
{ "id": … } |
| Update | PUT /api/{resource}/{id} |
200 | TItem, as it stands after the update |
| Delete | DELETE /api/{resource}/{id} |
204 | empty |
Four decisions inside that table are worth stating outright, because each has a plausible alternative that was not chosen:
The update answers with the updated resource, not 204. A list screen that has just saved a row needs to redraw it. Returning the new state costs the server a mapping it has already done and saves the client a second round trip; a 204 would force every client to re-read the resource to learn what its own write produced — including any field the server computed.
The identifier lives in the route, never in the body. A body that disagreed with the route would leave the server to pick a winner and the client to guess which. The update request record therefore carries the editable state only.
PUT replaces the editable state; there is no PATCH. The update command names every editable
field, and a field left out of the request is not "unchanged", it is empty. Partial update is a
separate convention and is not defined here — do not half-implement it by making fields nullable.
A delete of something that is not there is a 404, not a 204. The idempotent reading is defensible in the abstract, but a confirm-delete dialog that reports success for a row somebody else already removed hides a conflict the user should see.
Status codes
Every failure is signalled by throwing, never by returning a status from the controller.
BsftExceptionMiddleware (in Bsft.Hosting) catches the exception and writes an RFC 9457
application/problem+json document. The table below is the middleware's actual mapping, read off
BsftExceptionMiddleware.MapException:
Exception (Bsft.Core.Exceptions) |
Status | title |
Extra members |
|---|---|---|---|
ValidationException |
400 | Validation Error |
errors, errorCode, correlationId |
ForbiddenException |
403 | Forbidden |
errorCode, correlationId |
NotFoundException |
404 | Not Found |
entityType, entityId, errorCode, correlationId |
PaymentRequiredException |
402 | Payment Required |
errorCode, correlationId |
PreconditionFailedException |
412 | Precondition Failed |
errorCode, correlationId |
ConflictException |
409 | Conflict |
errorCode, correlationId |
PayloadTooLargeException |
413 | Payload Too Large |
errorCode, correlationId |
BusinessRuleException |
422 | Business Rule Violation |
errorCode, correlationId |
PreconditionRequiredException |
428 | Precondition Required |
errorCode, correlationId |
any other BsftException, or any unhandled exception |
500 | Internal Server Error |
correlationId |
Note the 403, which the CRUD discussion usually forgets: an authorization refusal is a
ForbiddenException and is part of the same contract. Note also that the 500 arm deliberately
replaces the exception message with a fixed sentence — a client cannot learn anything from a 500
except the correlation id, and that is the point.
Every document carries type, title, status, detail and the members above. errors is a
dictionary keyed by field name with an array of messages per field, which is directly what a form
needs to place messages next to inputs.
Every mapped arm also carries errorCode, the machine-readable discriminator a client branches on
within a status. BsftException carries an ErrorCode — the built-in subtypes set a stable one
(VALIDATION_FAILED, FORBIDDEN, NOT_FOUND, CONFLICT, PRECONDITION_FAILED,
PRECONDITION_REQUIRED), and BusinessRuleException takes its own as a second argument
(PRODUCT_HAS_STOCK in the sample). Two 422s, or two 409s, that a human reads apart by their
detail are told apart in code by their errorCode. Three of the built-in subtypes take a
caller-supplied code as a second argument for exactly that reason — ConflictException,
PreconditionRequiredException and PayloadTooLargeException — because a family with more than one
refusal at a status has to be able to name each of them; Bsft.Idempotency is the first user, and
publishes IDEMPOTENCY_KEY_REUSED and IDEMPOTENCY_IN_PROGRESS within one 409. The one exception is the 500 arm, which is
deliberately opaque: an unmapped framework error publishes no code, only the correlation id, so an
internal error taxonomy never leaks to the client.
Which refusal is which
The three client-fault codes are easy to blur, so the split is fixed:
- 400 — the request is malformed on its own terms: a missing name, a negative quantity, a price of zero. Decidable without looking at anything else in the database.
- 409 — the request is well-formed but collides with another row: a name that is already taken.
- 422 — the request is well-formed and collides with nothing, but the resource's own state forbids it: deleting a product that still holds stock.
A client can act on that split. A 400 belongs next to a field, a 409 usually means "reload and look again", and a 422 is a sentence for the user about the thing itself.
402 is the fourth, and it is about the caller rather than the request. A quota refusal or a
missing entitlement is not a defect in the request and not a collision with anything: the identical
request succeeds on a larger plan, or after the period rolls over. Bsft.Billing is its user, with
QUOTA_EXCEEDED and ENTITLEMENT_MISSING. The reason it is not folded into 422 is what a client
does with it — a 422 is a sentence for the user about the thing itself, while a 402 is the one status
a client can turn into an upgrade path without parsing prose.
Filtering: the filter query parameter
The list endpoint binds [FromQuery] PagedRequest. ASP.NET's own query binding reaches everything on
that request except one thing: Page, PageSize, Sort[i].Field/Sort[i].Direction and Search.Group/
Search.Term all bind from their own query keys, but PagedRequest.Filter is a polymorphic
FilterNode AST (Bsft.Data.Filtering) that query keys cannot express. A generic client — a table
bound to a resource, the UI framework's CrudApiClient — therefore has a transport for paging,
sorting and search but none for the filter, and that is the gap this section closes.
The filter travels as one query parameter carrying the JSON-serialized AST:
GET /api/{resource}?filter=<url-encoded JSON>. The decision to keep it a query parameter on GET,
rather than move the list to a POST body, is deliberate: the list stays a GET, which keeps it
idempotent and cacheable and keeps the whole list convention one shape. A custom model binder
(FilterNodeModelBinder, registered by AddBsftDefaults) deserializes the filter value over the
AST's own [JsonDerivedType] vocabulary and hangs the result on PagedRequest.Filter; the controller
signature does not change and request.Filter is simply populated.
The AST vocabulary
The wire format is the polymorphic AST, discriminated by $type:
$type |
Shape | Meaning |
|---|---|---|
comparison |
{ "$type": "comparison", "field": <name>, "op": <operator>, "value": <json> } |
one field compared to a value |
present |
{ "$type": "present", "field": <name> } |
the field has a value (is not null) — unary, no value |
and |
{ "$type": "and", "and": [ <node>, … ] } |
all child nodes must hold |
or |
{ "$type": "or", "or": [ <node>, … ] } |
any child node holds |
op is one of eq, neq, gt, gte, lt, lte, contains, !contains, startsWith,
!startsWith, endsWith, !endsWith. Presence (pr) is not an op on a comparison; because it
compares against nothing it is its own unary present node, so a comparison's value never has to be
faked. A minimal example, before url-encoding:
{ "$type": "and", "and": [
{ "$type": "comparison", "field": "name", "op": "contains", "value": "widget" },
{ "$type": "present", "field": "name" },
{ "$type": "comparison", "field": "price", "op": "lte", "value": 9.99 }
]}
Where the operator vocabulary comes from
The operator set is a documented choice, not an accident. There is no single RFC a REST filter language must obey, so the vocabulary borrows deliberately:
- Operator set and semantics: SCIM (RFC 7644 §3.4.2.2). SCIM is the only IETF RFC with a formal
filter grammar, and its operator set is the reference this vocabulary is audited against — the
comparison, string and presence operators, plus
and/orcombination. It is referenced for the operators and their meaning, not its wire format: SCIM filters are a string expression language (userName sw "J"), whereas this transport is the JSON AST above. filteras the reserved query-param name: JSON:API. JSON:API standardizes only thatfilteris the reserved parameter for filtering, which is exactly how this convention transports the AST — the JSON grammar inside it is left to the application, and this is that grammar.- OData
$filter(OASIS): deliberately not adopted. OData specifies a full expression language with functions, arithmetic and lambda operators. That is far more surface than a whitelist-checked list convention needs, and reimplementing an expression parser would re-open exactly the injection surface the JSON AST closes. It is rejected on purpose. - The JSON-AST family (MongoDB query operators, JsonLogic, GraphQL
whereinputs). A discriminated JSON tree of typed nodes is the shape this family shares;FilterNodesits squarely in it.
Node / op |
SCIM operator | Meaning | Applies to |
|---|---|---|---|
eq |
eq |
equals | any field |
neq |
ne |
not equals | any field |
gt |
gt |
greater than | comparable field |
gte |
ge |
greater than or equal | comparable field |
lt |
lt |
less than | comparable field |
lte |
le |
less than or equal | comparable field |
contains |
co |
substring match | string field only |
!contains |
(negated co) |
not a substring | string field only |
startsWith |
sw |
prefix match | string field only |
!startsWith |
(negated sw) |
not a prefix | string field only |
endsWith |
ew |
suffix match | string field only |
!endsWith |
(negated ew) |
not a suffix | string field only |
present node |
pr |
field has a value (not null) | any field |
and / or node |
and / or |
logical combination | — |
SCIM's not has no dedicated node: single-condition negation is expressed by the negated operators
(neq, !contains, !startsWith, !endsWith), which keeps the AST a positive tree the whitelist
compiler can reason about without a general negation wrapper.
The string operators (contains, startsWith, endsWith and their negations) are honored only
against string-typed whitelisted fields. A string operator aimed at a non-string field is a 400
keyed on filter, not a 500 — the type mismatch is caught before the expression is built. The value
a client sends is always data, never a LIKE pattern: the compiler emits string.Contains /
StartsWith / EndsWith, which EF Core translates to a parameterized, wildcard-escaped LIKE, so a
% or _ in the value matches literally and can never become an injected wildcard.
The filter is client-controlled, so it is bounded and whitelisted
The filter value is attacker-reachable input, and it is treated as such on three axes, none of which
ever produces a 500:
- Whitelist, not reflection. The AST is compiled only against the resource's
QueryFieldMap— the fields and operators aQueryConfigurationBase<TEntity, TDto>declares (forproducts,ProductQueryConfiguration). A field that is not declared, or an operator not allowed on it, is rejected by the compiler as aValidationException→ 400 keyed onfilter. There is no path from a filter to an arbitrary property or expression: an undeclared name never resolves to anything. - Size bounds against denial of service.
BsftFilterTransportOptionscaps the rawfilterlength (MaxFilterLength, default 8192 characters), the AST's nesting depth (MaxDepth, default 24) and its total node count (MaxNodeCount, default 200), and hands the JSON reader a hard recursion limit (MaxJsonReaderDepth, default 64). A filter that breaches any of these is a 400 raised before the AST is compiled — an oversized or deeply nested payload never reaches the expression builder, and a payload nested past the reader limit is a clean 400 rather than a stack overflow. The options are validated eagerly at startup and configurable throughAddBsftDefaults(d => d.FilterTransport(…)). - Malformed JSON is a 400, not a 500. A
filterthat is not well-formed JSON over this vocabulary — a broken document, an unknown$type, a missing required member — is aValidationException→ 400 throughBsftExceptionMiddleware, the same problem+json shape as any other validation failure. The parser echoes no fragment of the input back.
The honest limit, and the fallback that is named but not built
A GET carries the filter in the URL, and a URL has a length ceiling: proxies and servers commonly
refuse a request line beyond ~8 KB, which is why MaxFilterLength defaults there rather than pretending
the ceiling is not real. For all ordinary filters — a handful of conditions a user builds in a table —
this is ample. A host that genuinely needs filters larger than a URL can hold wants a POST-based
query variant (POST /api/{resource}/query with the PagedRequest in the body), which trades the
idempotent, cacheable GET for unbounded body size. That variant is named here as the documented
escape hatch but is deliberately not implemented in v1: it is a second transport for the same
operation, and adding it before a host actually needs it would fork the convention and the generic
client for a case none of the samples hit. It stays a documented extension point until a real host
justifies it.
Behind the endpoint
The controller does no work beyond translating HTTP into a command or query and back:
PUT /api/products/{id} → UpdateProductCommand → UpdateProductCommandHandler → ProductUpdatedNotification
- One command per mutation, marked
[AuditableRequest]so handling it is recorded as an activity. Create returns the new identifier, update returns the resource DTO, delete returns nothing (ICommand). - Every mutation publishes a notification —
Product{Created,Updated,Deleted}Notification— per architecture rule 1: the handler answers with an event and does not care whether anything listens. The deleted notification carries the name as well as the id, because the row is gone by the time a subscriber runs. - Validation is field-level and lives in an
IValidator<TCommand>, which the handler invokes and turns into aValidationException. Resource-level rules — uniqueness, state rules — stay in the handler, where the database is, and throwConflictExceptionorBusinessRuleException. Commands that share an editable field set share one validator, so a create and an update of the same values can never disagree about what is valid. - Load the row with a query, not
FindAsync. A primary-key lookup may be answered from the change tracker without reaching the database, and that path does not apply the global query filters — the tenant filter among them.FirstOrDefaultAsync(p => p.Id == id)is the form that makes another tenant's row a 404 instead of an edit.
Why the handler calls the validator itself.
ValidationBehavior<,>exists, but the dispatcher resolves behaviors — and theIValidator<T>instances they ask for — by the static type it dispatches under,ICommand<TResult>, never by the concrete command type. A validator registered asIValidator<UpdateProductCommand>is therefore never reached from the pipeline. See "Which behaviors apply to a request" in mediator.md. Injecting the validator into the handler is explicit, testable, and does not depend on that distinction.
Concurrency: optimistic, over ETag and If-Match
A resource that is edited directly needs a way to refuse a write that would silently overwrite a
change the writer never saw. The convention is optimistic concurrency carried in the standard HTTP
validators, and products implements it end to end:
- A concurrency token on the entity, projected into the resource DTO.
Productcarries aVersion(aGuid) marked[ConcurrencyCheck], so EF Core puts it into theWHEREclause of every update and delete;ProductDtocarries the sameVersion. AGuidthe handler rolls on every write rather than a providerrowversion, because the samples run on SQLite, which has no automatically maintained row-version column — a token the application owns is the provider-independent form of the same idea. - The token is a strong
ETagonGET.GET /api/products/{id}returns the resource and setsETagtoproduct.Version.ToVersionEntityTag()— the sharedBsft.Httpprimitive, which digests the token into the"sha256-<hex>"shape the framework's conditional-GET endpoints already serve. The update response carries the newETagtoo, so a client can chain a second write without a read in between. A resource adopting this convention derives its tags with that primitive rather than with a spelling of its own: the value a read hands out and the value a write is checked against have to be computed one way, and the token is digested rather than published so a monotonic counter does not become a public write count. If-Matchis required onPUTandDELETE. A conditional write made without anIf-Matchis aPreconditionRequiredException→ 428: the server will not guess which version the client meant to replace. AnIf-Matchthat no longer names the current version is aPreconditionFailedException→ 412: the writer's view is stale, and the answer is "read it again", not a last-write-wins overwrite. A matchingIf-Matchproceeds and answers 200 (update) or 204 (delete).
The comparison itself is Bsft.Http's IsMatchedBy, which is strong as RFC 9110 requires for
If-Match: * matches whatever version the resource has now, an exact tag matches, and a weak
validator or an unquoted spelling does not — a write is gated on the exact version the client last
saw, not on one that is merely equivalent for caching. The 428 stays in the endpoint, which is
where the header's absence is noticed and where the wording of the refusal belongs; see
http.md for the two headers side by side.
The handler fails closed: it compares the If-Match against the version it just read and throws the
412 before touching anything, so a stale write changes nothing. The [ConcurrencyCheck] token
closes the remaining window — where another writer commits between the read and the save — by
turning EF Core's DbUpdateConcurrencyException into the same 412. BsftExceptionMiddleware maps
both PreconditionRequiredException and PreconditionFailedException, so neither ever surfaces as a
500, and the sample keeps Bsft.Hosting free of any EF Core dependency by throwing the framework's
own exceptions rather than letting a provider exception reach the middleware.
A client's loop is therefore: read the resource, keep its ETag, send it back as If-Match on the
next write; on a 412, re-read and reconcile; on a 428, it forgot the header. Bsft.Sample.Orders
still opts out of all of this, for the same reason it opts out of PUT and DELETE — an order is a
workflow aggregate, not a directly edited resource.
Every list row carries its own validator
Read literally, the loop above costs a list screen one GET by id per row before every edit or
delete: the row is on screen, the user presses save, and the client has to fetch the resource it is
already showing purely to learn the tag its If-Match needs. That read exists only to move a value
the server already had when it built the list, so the list carries it instead.
A list item publishes its own entity tag, under etag. The contract is
IEntityTaggedItem in Bsft.Data.Contracts (Bsft.Data.Pagination), a single nullable ETag
member that a resource's list DTO implements; PagedResult<T> itself is unchanged, because the tag
belongs to the row rather than to the page. ProductDto implements it, so a page of products is a
page of writable rows:
{ "items": [ { "id": "…", "name": "Widget", "version": "…", "etag": "\"sha256-…\"" } ], "totalCount": 1 }
Four decisions inside that:
The tag is the same value the detail endpoint serves, not merely one like it. GET /api/products/{id}
and the row for that product at that version publish one string. That is not a convenience, it is the
whole contract: a list tag that differed in any byte would make every write issued from a list row a
412 — the failure would look like a concurrency conflict and be a spelling mistake. The sample
guarantees it structurally rather than by discipline, by deriving the tag in exactly one place —
ProductMappingProfile, which the list projection, the read by id and the update response body all
pass through — with Bsft.Http's ToVersionEntityTag. The endpoint then writes the tag the DTO
already carries into the ETag header instead of deriving a second one from the version.
The tag is derived, not the version republished. The version is in the list DTO too, and a client could in principle hash it itself — but the derivation is a canonical SHA-256, and a client reimplementing it is a second spelling waiting to drift. The server does the one hash per row it is already able to do, over one short string, and the client treats the result as opaque.
It is optional, and null means "read me first". The member is nullable so the contract is purely
additive: a resource with no concurrency token, or a host that has not adopted conditional writes,
publishes no tag and behaves exactly as before. A client that finds no tag on a row falls back to the
read it used to make. A blank tag is not a third state — ToVersionEntityTag refuses a blank version
rather than minting a validator that matches nothing.
The page itself carries no ETag. A page is an assembled view of many resources; a validator over
the whole page would be invalidated by a write to any row on it, and a client holding it could not
tell which row moved. Validators sit on the items, where the writes are.
None of this weakens the check. Skipping the read does not skip the comparison: the tag a client took from a list row is compared strongly against the version the row actually has when the write arrives, so a row that went stale between the list and the write is still refused with a 412 — the client just learns it one round trip later than it used to, instead of one round trip earlier for every row that had not gone stale at all.
One caveat inherited from the derivation: the tag is a hash, so it has to be computed on materialized
rows. A resource that projects to its DTO inside the query — ProjectTo over IQueryable — derives
its tags in a Select after the page comes back, because no database provider translates a SHA-256.
Idempotency on unsafe endpoints
Concurrency answers "did somebody else change this since I read it". The other half of the same bargain is "is this the same request I already ran", and the five endpoints answer it very unevenly — which is the point of stating it here rather than leaving each resource to work it out.
PUT and DELETE are already idempotent by construction, and need no key. A PUT replaces the
editable state of a named resource, so sending it twice leaves the same state; a DELETE removes a
named row, so the second attempt is the 404 the section above insists on. GET changes nothing.
Sending an Idempotency-Key on any of them is harmless and is simply ignored.
POST /api/{resource} is the one that needs it. It creates a row whose identifier the server
mints, so a retry that the client cannot tell from a timeout creates a second row — two orders, two
invoices, two charges. Bsft.Idempotency is the framework's answer: [Idempotent] on the action, or
.RequireIdempotency() on a minimal-API group, and a repeat under the same key replays the first
response instead of running again. See idempotency.md.
Two consequences for this convention specifically:
- A replay of a create returns exactly what the create returned, 201 with its
Locationheader and the same{ "id": … }body, byte for byte. That is why the record stores the status, an allowlist of headers and the body rather than merely "already done": a client that retried has to be able to find what it created, and a replay that dropped theLocationwould leave it unable to. The replay is marked withIdempotency-Replayed: true, so the two are distinguishable without comparing bodies. - There is no
PATCHin this convention, and that is not whyPATCHis excluded. The onePATCHthe framework serves —PATCH /api/settings/me/{key},application/merge-patch+json— is not idempotent in general, so it is opt-in-able rather than excluded by design: a host adds it toBsftIdempotencyOptions.Methods. The default considersPOSTalone.
The refusals join the table above rather than inventing a vocabulary: a missing key is the same 428
as a missing If-Match, told apart by its errorCode; a key reused for a different request and a
key still in flight are both 409s, likewise told apart by their codes; a body too large to
fingerprint is the 413.
Reference implementation
samples/Bsft.Sample.Inventory, resource products:
| Piece | File |
|---|---|
| Endpoints | Controllers/ProductsController.cs |
| Commands | Commands/{Create,Update,Delete}ProductCommand.cs |
| Handlers | Commands/{Create,Update,Delete}ProductCommandHandler.cs |
| Validation | Commands/ProductCommandValidator.cs |
| Entity-tag derivation | Mapping/ProductMappingProfile.cs |
| Notifications | ../Bsft.Sample.Inventory.Core/Events/Product{Created,Updated,Deleted}Notification.cs |
Bsft.Sample.Orders is deliberately not a CRUD resource: an order is a workflow aggregate driven
by a saga, and neither a blanket PUT over its state nor a DELETE of a placed order would be
meaningful. A resource is a CRUD resource when its state is edited directly; when it is advanced by
a process, model the transitions as their own commands and endpoints instead of forcing this shape
onto it.