#65 — Tech Debt: TodoListDto Construction Is Hand-Rolled in Two Query Handlers #65
Labels
No labels
priority/could
priority/must
priority/should
priority/wont
status/blocked
status/claimed
status/done-migrated
type/bug
type/feature
type/infra
type/tech-debt
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
robert/todo#65
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Tech Debt:
TodoListDtoConstruction Is Hand-Rolled in Two Query HandlersReported by: Backend Engineer, spotted during
#62's code review, 2026-07-22Symptom
GetTodoListQueryHandler.csandGetTodoListsOfCurrentUserQueryHandler.cseach hand-build aTodoListDtopositionally inside a LINQ.Select(...):duplicated verbatim across both files.
TodoMappings.csandUserMappings.csalready solve theequivalent "entity to DTO" projection problem with a Mapperly
ProjectToDto(this IQueryable<TEntity> entity)extension (seeCqsTodo/Features/Todos/GetTodosOfCurrentUserQueryHandler.csfor a caller);
TodoListMappings.cshas no equivalent, so this is the one remaining place in theTodoListsfeature area without that safety net.If
TodoListDtogains a new optional/defaulted member, both hand-rolled.Select(...)projections would keep compiling while silently leaving the new field at its constructor default —
the same "silently defaults on the wire" failure mode that
#62fixed forTodoListDto->TodoListBroadcastDto, just one layer upstream (entity ->TodoListDto).Why this wasn't folded into
#62#62was scoped to theTodoListBroadcastDtomapping specifically.TodoListDto.CurrentUserRoleis computed from the requesting user's membership row (
x.Users.Where(u => u.UserId == userId)...), which is external, per-request context — not a value derivable fromTodoListEntityalone. A generic
ProjectToDto(this IQueryable<TodoListEntity> entities)extension (matching theTodoMappings/UserMappingsprecedent exactly) can't compute this field without also takinguserIdas a parameter, which is a different shape of Mapperly usage than the existingprecedents (
TodoMappings.ProjectToDtoandUserMappings.ProjectToDtohave no such externaldependency). Whoever picks this up should evaluate whether Mapperly supports a clean way to
express that (e.g. a partial method with an extra parameter, or a
[MapProperty]reference to ahelper), or whether the two hand-rolled projections should be de-duplicated a simpler way (e.g. a
shared private static expression) without adopting the
ProjectToDtopattern verbatim.Expected behaviour
Either a Mapperly-based projection (if a clean parameterized
[Mapper]shape is found) or ashared, single hand-written projection expression used by both
GetTodoListQueryHandlerandGetTodoListsOfCurrentUserQueryHandler, so a newTodoListDtofield only needs to be added in oneplace.
Acceptance criteria
TodoListDtoconstruction inGetTodoListQueryHandler.csandGetTodoListsOfCurrentUserQueryHandler.csis de-duplicated into a single definitionTodoListDtowithout updating that single definition either fails thebuild (preferred, if a Mapperly shape is found) or is at minimum only one place to fix by hand
CqsTodo.Tests/Mappings/TodoListMappingsTests.csfrom#62for the pattern of testing a mappingdirectly without Docker/Testcontainers)
Agents involved
Blockers
None.
design (
65_todolist_dto_projection_hand_rolled_design.md)#65— Design NoteDecision: hand-written shared expression, not a Mapperly
ProjectToDtoEvaluated the Mapperly-based approach the story's own "why this wasn't folded into
#62" sectionraised as an open question.
TodoMappings.ProjectToDto/UserMappings.ProjectToDto's existingprecedent is parameterless (
this IQueryable<TEntity> entity) because none of their target DTOsneed external, per-request context.
TodoListDto.CurrentUserRole/CurrentUserEmailNotificationModeboth need the requesting
userId, whichTodoListEntityalone doesn't carry — Mapperly cansupport additional mapping-method parameters, but confirming it generates a correct,
EF-translatable expression for a member that isn't a direct property copy (it needs a
.Where(u => u.UserId == userId).Select(...).FirstOrDefault()sub-query referencing the extraparameter) isn't something this sandbox can verify empirically — no Docker/Postgres available to
confirm the generated SQL actually works, and Mapperly's exact codegen behavior for this shape
wasn't something to guess at for a live per-request query path.
Chose the story's own explicitly-offered fallback instead: a single hand-written
TodoListMappings.ProjectToDtoFor(UserId? userId) : Expression<Func<TodoListEntity, TodoListDto>>,identical in body to the two (now-deleted) inline lambdas —
userIdis still just a capturedclosure variable, exactly as it was when each handler captured its own local variable inline;
only the lambda's declaration site moved from inline-in-the-handler to a shared static method's
return statement. EF Core's expression-tree translation doesn't care where a captured variable's
closure originates, only the final
Expression<Func<TSource,TResult>>shape once.Select(...)receives it — structurally unchanged from before.What this satisfies vs. the story's stated AC
expression doesn't get Mapperly's
RequiredMappingStrategy.Sourcecompile-time enforcement.Falls back to the AC's explicitly-allowed second option instead: "at minimum only one place to
fix by hand" — satisfied, since a new
TodoListDtofield now only needsProjectToDtoForupdated once, not two near-identical
.Select(...)bodies kept in sync by hand.TodoListMappingsTests.cs, following the exact.Compile()-and-invoke-against-an-in-memory-entity pattern that file's existing tests (from
#62) already established for testing a mappingwithout Docker/Testcontainers. All 3 run and pass locally (pure C#, no DB needed).
Pre-existing quirk, unchanged by this refactor (noted, not fixed)
TodoListUserRole.Owneris ordinal0, so.FirstOrDefault()on a no-membership-row casereturns
default(TodoListUserRole)==Owner, not a genuinely neutral "no role" value — anon-member could, in principle, see
CurrentUserRole: Owneron a list they don't belong to.Verified this is identical to the original hand-rolled code (not introduced by this refactor) and
that it's unreachable in practice: both call sites are gated by authorization
(
AuthorizeTodoListAccessForCurrentUserQuery/AuthorizeIsCurrentUserAuthenticatedQuery+.Where(x => x.Users.Any(u => u.UserId == userId))) that already excludes non-members before thisprojection ever runs. Not worth a follow-up ticket given it's already unreachable under existing
authorization, but noted here in case a future caller of this projection method skips that gate.
Verification boundary (honest limitation)
Reasoned rather than empirically confirmed that EF Core translates the shared expression
identically to the original inline lambdas — no Docker/Postgres available in this sandbox to run
the actual query against real Postgres and inspect the generated SQL. The transformation is
low-risk (pure syntactic extraction of an already-correct, already-shipped expression, not new
logic), unlike
#63's decorator-wiring change earlier this session which did require liveverification to catch two real bugs. The full backend test suite (307 pre-existing
Docker-dependent failures, unchanged; 56 passing, up from 53 with the 3 new tests) shows no
regression in anything that can run locally.