ADR-013: REST API CRUD Conventions
Status
Implementation status (2026-06-29)
The staged cleanup in docs/plans/api-consolidation-plan.md is essentially complete:
- Phases 0β6 (shared-kernel contract, dead-spec removal, live-route documentation, convention conformance, mutation-model fixes, partner consolidation, events-api decomposition) β done.
- Phase 7 (contract-first completion) β done across all three active services. Every
wireable controller in EMS, CUMS, and Partner now
implements <X>Apiagainst a generated OpenAPI interface (no hand-rolled REST controllers remain except dev/test-onlyDevEmail/TestFixtureCleanupand the externalWatch*app contract). Combined with the frontendβsopenapi-typescripttypes, both ends of every service are now generated from the OpenAPI specs β the contract is the single source of truth end-to-end. - Phase 8 β
GET /public/settings/featuresrelocated offAiAssistControllerto a dedicatedevent-app-settings-api+PublicSettingsController(8.1)./attendee-portal/dashboardrelocation deferred (attendee-experience dormant). Watch-path normalization deliberately skipped (the generatedWatchApiis unimplemented and the iOS watch app vendors its own spec, so the inconsistency is latent/harmless). - Phase 9 β generator upgraded to 7.14.0 (9a); top-level
tagsdeclared in the remaining specs; staleworkflowService.tsJavadoc fixed.
See the plan for the per-controller record.
Context
BATbern has ~13 OpenAPI specs across five services. An audit during the api-consolidation initiative (2026-06-25) found that the read model and action endpoints are largely sound, but the mutation model and shared contract were governed per-story rather than by a written rule. The result is drift:
Problems found
-
PUT and PATCH are redundant twins.
companies-api(PUT/PATCH /companies/{name}) andusers-api(PUT/PATCH /users/me) both reuse a single all-optionalUpdateXRequestfor both verbs. PUT is documented as βfull replacementβ but cannot enforce it (no required fields), so the two verbs are functionally identical. Clients have no principled way to choose.events-apigot this right (UpdateEventRequest9-required for PUT vsPatchEventRequest0-required for PATCH) β that is the reference. -
No documented PUT-vs-PATCH rule existed.
coding-standards.mdand04-api-core.mdare silent on verb semantics, so every service invented its own (companies/users: PUT+PATCH twins; partner domain: PATCH-only; topics/sessions: PUT reusing the create schema for partial updates). -
Three competing state-transition idioms for one concept. EMS event lifecycle has
POST β¦/publish,POST β¦/workflow/advance, andPUT β¦/workflow/transition. The partner domain mixes action-PATCH (/status), boolean-in-PATCH (isActive), action-POST (/send-invite), and POST/DELETE toggle (/vote). -
Convention drift between sibling specs. get-or-create is
:get-or-create(colon, companies) vs/get-or-create(slash, users). List filtering is JSON:APIfilter/sortin some specs and ad-hocrole/company/sortBy/sortDirin others β andlistUsers/listNewsletterSubscriberscarry both vocabularies at once. -
Redundant / orphaned endpoints.
users-apiships two presigned-URL upload initiators doing the same thing.events-apicarries a stale UUID-based/topics/{id}block that the liveTopicController(which serves{topicCode}) does not implement.partners-apidocuments an entire weighted topic-voting subsystem (/partners/{companyName}/votes,/suggestions) that has zero implementation. -
Six different error envelopes and two pagination shapes. shared-kernel even has two
ErrorResponseclasses (dto.ErrorResponsewithcorrelationIdvsutils.ErrorResponsewithtraceId). Onlyevents/partnersreference the shared types via the ADR-006 stub pattern;companies/users/topicsre-declare full inline copies that the generator silently ignores; six specs are wired to no generator at all.
This ADR establishes the binding conventions. It governs simple-entity CRUD (User, Company, Partner, Topic, Note, Meeting, β¦) and the shared contract every spec must use.
Decision
1. Mutation verbs β the decision tree
For any resource, pick exactly one path through this tree. Do not ship two verbs that accept the same body.
| Intent | Verb + shape | Rule |
|---|---|---|
| Create | POST /collection + Create<Entity>Request |
Genuinely-required fields are required. Returns 201. |
| Partial field update (default) | PATCH /collection/{id} + all-optional Update<Entity>Request |
This is the default mutation. The body is all-optional; only present fields change. |
| Full replacement (only if real) | PUT /collection/{id} + Replace<Entity>Request with invariant fields required |
Ship PUT only if a caller genuinely replaces the whole document (e.g. a full-form editor). Its body MUST mark the invariant fields required, making it semantically distinct from PATCH. If you cannot articulate that difference, do not ship PUT β PATCH alone suffices. |
| State transition w/ side-effects (verify, publish, deactivate) | POST /{id}/{verb} action sub-resource |
Idempotent. Not a boolean PATCH. Publishes domain events here, not in CRUD. |
| Replace a bounded sub-collection (roles, tags) | PUT /{id}/roles with the full set |
Whole-set replacement of a child collection. |
| Toggle a membership/flag relationship | POST / DELETE /{id}/vote |
Idempotent both directions. Preferred over a boolean field in PATCH. |
Forbidden: per-attribute endpoints (/setEmail, /updateBio) β that is PATCHβs job,
unless the change is itself a domain event (then it is a state transition, row 4).
Rule of thumb: PATCH is the default. PUT is opt-in and must earn its place with a required-field body. Pick one transition idiom per service and hold it (preference: action-POST sub-resource for side-effecting verbs; POST/DELETE toggle for flags).
2. get-or-create
Use POST /{collection}/get-or-create (slash form) everywhere. Return 200 when an
existing resource is returned, 201 when one is created. The colon :get-or-create custom-
method form is retired. (Where feasible, prefer an explicit POST-create + separate
select/assign over get-or-create, as EMS does β it sidesteps the idiom entirely.)
3. Querying lists β one vocabulary
A GET /collection list endpoint uses the JSON:API-style vocabulary only:
filter, sort, page, limit, fields, include. Do not also expose ad-hoc
role / company / sortBy / sortDir on the same endpoint. A separate GET /collection/search?query= is permitted only for full-text search distinct from
structured filtering β it must not duplicate filters already expressible via filter.
4. Read variants
/{collection}/me (self), /{collection}/{id} (authenticated/admin), and
/public/{collection}/{id} (anonymous, reduced fields) are the only sanctioned read
splits β and only where an auth-scope difference justifies them. Do not create read
variants that differ by anything other than auth scope / field exposure.
5. Identifiers (reaffirms ADR-003, narrows the βeliminate UUIDsβ claim)
Top-level domain resources MUST use meaningful IDs in the path (eventCode, username,
companyName, topicCode). UUIDs are permitted for transient or purely-internal
sub-resources (photos, posts, uploads, meetings, notes) where no meaningful ID exists β
ADR-003βs blanket βeliminate UUIDsβ is hereby narrowed to βno UUIDs for top-level domain
resources.β Never expose a UUID where a stable meaningful ID exists.
6. Shared contract β error + pagination (one source of truth)
- Error envelope: every 4xx/5xx references the single canonical
ch.batbern.shared.dto.ErrorResponse(timestamp, path, status, error, errorCode, message, correlationId, severity, details). The duplicatech.batbern.shared.utils.ErrorResponseis deleted. Field iscorrelationId(nevertraceId/requestCode/statusCode). - Pagination: list responses wrap data in
PaginatedResponse<T>withPaginationMetadata(page, limit, totalItems, totalPages, hasNext, hasPrev). Page-based, not offset/cursor. - Reuse, donβt redefine (single source via
$ref): the canonical schemas live once indocs/api/_shared.openapi.yml(fullproperties+x-java-type). Every spec references them:$ref: './_shared.openapi.yml#/components/schemas/PaginationMetadata'. This works for both generators (verified 2026-06-26):openapi-typescript7.x resolves the external$ref(Redocly bundling) β full TS type.openapi-generatorresolves the$refand maps by name viaschemaMappings/importMappingsβ the shared-kernel class, no duplicate DTO.- β Do not use a body-less
x-java-typestub (noproperties): it works for Java butopenapi-typescriptemits an emptyRecord<string, never>and breaks frontend types. (This corrects the earlier stub guidance β see ADR-006.)
ValidationError(field,message) lives in_shared.openapi.ymland is referenced for field-level validation failures.- A spec that is implemented and generator-wired must
$refthe shared schemas, not redefine them. (Dormant/un-wired specs for consolidated-away services β see ADR-014 β are exempt.)
7. Documentation must match reality
04-api-core.md is corrected to document the flat ErrorResponse and page-based
pagination that are actually implemented (it previously documented a nested-error,
offset-pagination contract that nothing implements).
Consequences
Positive
- One answer per question: consumers and the dev agent stop re-forking conventions per story.
- PUT/PATCH redundancy, orphan endpoints, and dead spec blocks are removed.
- A single error + pagination contract makes generated SDKs consistent.
Costs / risks
- Removing shipped routes (events-api UUID
/topics/{id}, partners-api voting) is a contract change β verify no live client depends on them first (the servers already only serve the canonical routes, so UUID/voting callers are already broken). - Re-wiring the commented-out speaker/attendee generators may surface latent spec/impl drift.
- The cleanup is staged β see
docs/plans/api-consolidation-plan.md.
Reference patterns (the org standard, by example)
- PUT-as-full-replace vs PATCH-as-partial:
events-api/events/{eventCode}. - POST-create + PATCH-update split:
events-apispeaker-pool (addSpeakerToPool/patchSpeakerPoolEntry). - Action sub-resources: EMS speaker
β¦/confirm,β¦/decline,β¦/promote; CUMSβ¦/verify,β¦/roles. - Idempotent toggle:
partner-topics-apiPOST/DELETE /partners/topics/{id}/vote. - Single error schema for 100% of error responses:
events-api/topics-api.
Related: ADR-003 (meaningful identifiers), ADR-006 (OpenAPI contract-first).