All five write use cases follow “read/validate → Store write → local tag removal → sync event,” but current business validation and atomicity remain limited.
1. Write path
All write endpoints authenticate by default, and every command implements IAuthorizedRequest.
2. CreateMenu
Create checks only nonblank Name and Code and normalizes blank ParentId to null. It does not validate the parent, URL, Scope, order range, duplicate Code, or icon format.
// ① Code is the stable key shared by Menu and Authorization.var command = new CreateMenu.Command( ParentId: null, Name: "Orders", Code: "orders", LinkUrl: "/orders", Icon: "shopping-cart", OrderSort: 20, IsMenu: true, Enabled: true, Scope: 0);
// ② Caller needs menus.menu.create; the unique index is the final conflict guard.var result = await mediator.Send(command, cancellationToken);Mapping a uniqueness exception depends on the shared exception pipeline; the handler does not return a typed duplicate error itself.
3. UpdateMenu
Update reads by Id and returns Menu.NotFound on a miss, then overwrites nine managed fields. It does not repeat Create’s Name/Code validation or protect Code identity.
It can therefore store a blank Name/Code or make ParentId point to self or a descendant. A production API should use one validator and model reparenting as a separate transactional operation.
4. SortMenu
Sort changes only the selected OrderSort:
public static class MenuErrors{ public static readonly Error NotFound = Error.NotFound("Menu.NotFound", "Menu does not exist");}
// ① A missing row returns Menu.NotFound.var entity = await menuStore.GetByIdAsync(request.Id, cancellationToken);if (entity is null) return Result.Failure(MenuErrors.NotFound);
// ② No sibling moves, and equal values are allowed.entity.OrderSort = request.OrderSort;await menuStore.UpdateAsync(entity, cancellationToken);
// ③ All menu projection caches are removed after the write.await CreateMenu.InvalidateCacheAsync(cacheStore, syncNotifier, cancellationToken);Equal OrderSort values have no guaranteed secondary ordering, and base row order can differ by database.
5. ToggleMenu
Toggle overwrites one Enabled flag. Reads exclude disabled rows, so disabling a parent hides its reachable descendants without changing their state.
Current behavior supports temporary parent hiding. It does not meet a requirement that disabling a folder persistently disables its whole subtree.
6. DeleteMenu
Delete checks the root, recursively loads direct children, soft-deletes each subtree depth-first, and finally deletes the root.
No Unit of Work wraps the tree. A mid-run failure leaves partial soft deletion and skips cache invalidation. A cycle prevents normal recursion termination.
7. Typed failures
The explicit application errors are:
| Error | Trigger |
|---|---|
Menu.InvalidInput | blank Name/Code in Create |
Menu.NotFound | missing Id in detail/update/delete/sort/toggle |
There are no dedicated errors for missing parents, duplicate Codes, cycles, concurrent overwrite, or authorization references.
8. Invalidation order
The shared helper removes the tag first, then publishes:
// ① Remove tree, flat, and navigation entries on this instance.await cacheStore.RemoveByTagAsync(MenuCacheTags.Menus, cancellationToken);
// ② Broadcast Cache type, menus resource, and global tenant "0".await syncNotifier.NotifyAsync( LocalResourceTypes.Cache, "menus", "0", LocalResourceAction.Updated, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), cancellationToken);If cache removal or notification fails after persistence, the request fails but the database change does not roll back automatically.
9. Sync consumer
MenuCacheSyncConsumer.ResourceType is generic Cache. Handle does not check event.ResourceId == "menus", so any Cache event routed to it clears the Menu tag.
Non-cancellation exceptions are logged and suppressed. A message may appear consumed even though invalidation failed; there is no module-specific retry/dead-letter evidence.
10. Consistency windows
The 15-minute TTL is a final bound, not a strong consistency guarantee. GA should consider an outbox, versioned catalog snapshots, retry/dead-letter, and cache-staleness telemetry.
11. Concurrency semantics
Store update/delete predicates contain Id only, with no version or old-value check. Concurrent administrators get last-write-wins behavior; Sort and Toggle have no compare-and-swap.
EntityBase may supply audit columns, but this UpdateWhere builder has no explicit concurrency condition. The handbook must not claim optimistic concurrency.
12. Production management guidance
Restrict platform-admin permissions; show cross-tenant impact; make Code read-only by default; detect cycles before reparenting; export subtree and grant references before deletion; verify both database and navigation afterward; and do not blindly replay a non-idempotent Create after an ambiguous failure.
Long term, model create, rename, reparent, reorder, toggle, and delete as distinct operations with explicit validation, audit, and rollback semantics.
13. Test checklist
- Create blanks and uniqueness conflict;
- Update blank Code, self-parent, descendant-parent;
- Sort equal values, concurrent writes, deterministic secondary order;
- parent/child Toggle projection;
- Delete partial failure, deep tree, cycle, grant references;
- local invalidation and publisher failures;
- remote duplicate, reordered, and lost events;
- 15-minute TTL fallback.
Current automation does not cover these command and cache-sync semantics.
14. Inspection commands
# All five writes and the shared invalidation helper.rg -n "InvalidateCacheAsync|InsertAsync|UpdateAsync|DeleteDescendantsAsync" src/Platform/Menu -g '*.cs'
# Transaction, outbox, and concurrency support; expect no material Menu hits.rg -n "UnitOfWork|Transaction|Outbox|Concurrency|RowVersion" src/Platform/Menu -g '*.cs'