Omitted Is Not Empty
What sending a child list to the API really means
Real forms edit graphs, not rows: an order with lines, a party with addresses, an article with its images. The parent row travels in one PUT with its children inside it, and that raises a question the row-only case never had. Take PUT /articles/1 in the write-pipeline sample. One client is the article's edit screen, which shows the images and lets the user remove them; another is a small form that renames the article and has never loaded the images at all. Both send an article. What should the server do with the images when the second client's payload has none?
Two readings are defensible, and they cannot both be the default. Either the missing collection means "I did not touch this — leave the images alone", or it means "the article now has no images — delete them". Get it wrong one way and every rename wipes the images; get it wrong the other way and no client can ever remove the last image. The previous post kept a PUT from blanking a scalar the form never showed; this one asks the same question of a child list: how a Regira Entities API tells the two readings apart, and what that decides for the input DTO and for the form.
Universal or Regira? The contract — a missing collection means untouched, an empty one means sync to empty — is a decision every API with nested writes has to take, and the nullable property that carries it is plain C#. The diff Related() runs on save is Regira Entities; the _deleted marker and the prepareItem strip are @regira/modules.
What Related() does with the collection it receives
A parent registered with e.Related(x => x.Images) treats Images as an owned collection: the children have no service of their own, and the parent's save is where they are written. On a save, the service diffs the incoming collection against the stored one — rows present are kept or updated, rows absent are removed. Leaving a row out of the array is how a client deletes it.
A diff against nothing removes everything, which is what makes the question sharp. So the contract draws the line between not sent and sent empty:
On the parent's input DTO,
nulland[]are opposites. Anullcollection — unsent, or left off the input DTO entirely — means "not my save — touch nothing". An empty array means "sync to empty" — delete every child row. A client that "tidies" absent collections into empty arrays is issuing mass deletes.
The sample seeds article 1 with cover.png (id 1) and diagram.svg (id 2). The three shapes of the array, in this order ($URL as in the first post):
# sent: keep 1, leave 2 out, add a row with a client temp id
curl -X PUT "$URL/articles/1" -H "Content-Type: application/json" \
-d '{"id":1,"title":"Release notes 6.2","coverImageId":1,"images":[{"id":1,"fileName":"cover.png"},{"id":-1,"fileName":"changelog.png"}]}'
# -> "images":[{"id":1,"fileName":"cover.png"},{"id":3,"fileName":"changelog.png"}]
# omitted: a rename that never loaded the images
curl -X PUT "$URL/articles/1" -H "Content-Type: application/json" \
-d '{"id":1,"title":"Release notes 6.4","coverImageId":1}'
# -> "images":[{"id":1,"fileName":"cover.png"},{"id":3,"fileName":"changelog.png"}]
# empty: sync to nothing
curl -X PUT "$URL/articles/1" -H "Content-Type: application/json" \
-d '{"id":1,"title":"Release notes 6.4","coverImageId":null,"images":[]}'
# -> "images":[]
Only the client that took responsibility for the images gets to change them.
The rule for the input DTO
The contract lands on the .NET side as one modeling rule: an owned collection on the input DTO is nullable, and null means untouched. The sample's:
public class ArticleInputDto
{
public int Id { get; set; }
public string? Title { get; set; }
public int? CoverImageId { get; set; }
public ICollection<ArticleImageInputDto>? Images { get; set; } // null: not sent — leave the images alone
}
What breaks it, tried on the sample:
- An initializer on the input DTO —
= [], so it is "never null" — turns every omitting client into the mass-delete case: the JSON binder leaves the initializer in place when the key is absent, and the rename above deletes both images. - An initializer on the entity is harmless over the API, since the mapper writes the DTO's
nullover it, but not in process: a seeder or a job that savesnew Article { Id = 1, Title = … }sends the initializer's[]. Add one to the sample'sArticleand its own seeder deletes the images that way. - Leaving the property off the DTO is safe, since the server never sees the images and never syncs them, but then no client can edit them through that endpoint: fine for a rename endpoint, wrong for the main one.
Keep the property, keep it nullable, and let absence mean absence.
The same rule reaches the client, in the library's own collection editor: it normalizes an unset modelValue to [] when it mounts. Right for a form that really edits the collection — the rows were loaded into it, and an empty editor means the user emptied it — and the reason not to mount one over data you did not load: an empty editor over an unloaded collection is the tidy-into-[] mistake with a UI on top.
The client's half: the _deleted convention
If leaving a row out of the array deletes it, the form needs a way to let the user remove a row without losing it before the save is confirmed — the user may change their mind, and the save may fail. The client convention in @regira/modules is a _deleted marker: the removed row stays in the array, rendered struck-through and undoable, until the save. Rows added in the same session are dropped on removal, since there is nothing to undo; the negative temp id a list editor gives them is cleared by the server before the insert, as the first call above shows.
The underscore prefix is not decoration. EntityServiceBase.prepareItem strips every top-level property starting with _ before a save — transient client state never reaches the wire. But the strip does not recurse, so _deleted child rows are still sent unless you drop them yourself. That's the one override an entity service with owned collections almost always carries:
protected override prepareItem(item: Article): Article {
item.images = item.images?.filter((i) => !(i as any)._deleted)
return super.prepareItem(item)
}
Filter the marked rows out and the server receives the diff's input: the rows the user kept. The marker, the temp ids and this filter all follow from the server's contract; the Vue side of the form (cancel, save, refused deletes and restore) is in Edit Forms That Survive Reality.
Four places you will meet this
| Situation | Shortest correct form |
|---|---|
| The main edit endpoint | ICollection<ArticleImageInputDto>? Images { get; set; } — nullable, no initializer; sent means synced, omitted means untouched |
| A rename, a status form, any partial update | send no images at all; the stored rows stay |
| A collection editor in a form | mount it only over rows you loaded; its [] means the user emptied the list |
| A row the user removed in the form | keep it in the array as _deleted, filter it out in prepareItem; the diff deletes it on save |
Why not a DELETE /articles/1/images/2? Owned children have no life of their own: the parent's save is the only place they are written, so the array is the collection's full state and leaving a row out of it is the delete. One PUT carries the edit.
Docs: Regira Entities — related entities and owned collections; Regira Modules — vue/entities: services and list-item inputs.
Versions: the sample pins Regira.Entities.Web and Regira.Entities.Mapping.Mapster 6.4.0. Since 6.5.0 a temp id is cleared at any depth; through 6.4, a new row inside an existing child's own list failed the update.