ADR-0014 — Write-side relations: associate by id, no deep nested writes
Status: accepted
Context
Once responses can embed relations (include=owner), the symmetric request is obvious: if a read returns a nested object, why can't a write send one? The two are not symmetric, though. A deep nested write — POST /cats {"name":"Kit","owner":{"name":"Rae","email":"…"}} — has to decide, for every node in the payload: create or update? match on what? what happens to children that are absent, is that "leave alone" or "delete"? which failures roll back which parts? Every one of those is a policy decision the framework would be inventing on the caller's behalf, and getting any of them wrong corrupts data rather than returning a 400.
Decision
v6 supports association by id and nothing deeper. On create, update, and patch, a relation property accepts:
- a scalar id —
{"owner": 7}; - a reference object —
{"owner": {"id": 7}}; - an array of either for a to-many —
{"tags": [1, {"id": 2}]}; nullto disassociate.
The default deserializer normalizes all of these to { id } references and narrows anything else away: {"owner": {"id": 7, "name": "Rae"}} writes the association and drops name. A nested object is never a cascade; the framework does not partially honor a deep write.
Relations join the derived write shape by default, so association works with zero config. An entity with a registered create/update DTO opts in by declaring the property (owner: number | null = null), which also documents it in Swagger.
Deep nested writes are out of scope, not merely unimplemented.
Consequences
- The write surface stays predictable: one request writes one row plus its foreign keys. Failure modes are the ones CRUD already has.
- Multi-entity writes are expressed where their policy is visible — a hand-written controller method (an
@Override'd standard operation, or a fully custom route per issue #26) that spells out the order, the matching rule, and the failure behavior for that specific case. - ORM caveat, deliberately not papered over: setting a to-many by id only persists where the ORM supports it from the non-owning side (TypeORM needs
cascadeor the owning side / a join table). Kavo maps the payload; it does not synthesize writes the ORM declined to make. - The extension point, if a later version wants deep writes: they belong at the deserializer seam plus an explicit per-relation
writepolicy on the relation descriptor — additive, and it would arrive with the matching/orphan rules stated rather than assumed.