Debugging Patterns from Waypoint M0–M4
This page exists for one reason: Relay is next, and every pattern below cost real time to find once. Check this list before re-discovering any of them from scratch. If you hit something during Relay's build that isn't here, add it — that's the whole point of this document existing.
Each pattern below follows the same shape: what happened, why it happened, how it was actually found, and what to check for next time.
1. A database default can silently override explicit domain code
What happened: LeaveRequest.Create() explicitly set Status = LeaveStatus.Pending. Every single request submitted through the API — including ones hit directly via Swagger, bypassing the frontend entirely — landed in the database as Approved instead. No exception, no warning, no log line. It looked like the domain code was wrong, even though it wasn't.
Root cause: LeaveRequestConfiguration had a leftover HasDefaultValue(LeaveStatus.Approved) on the Status column, dating back to an earlier version of LeaveRequest.Create() that really did hardcode Approved. When the domain logic was rewritten to default to Pending, the EF configuration was never updated to match.
The mechanism that made this so hard to spot: Pending is the enum's first member, so its ordinal is 0 — which is also the CLR default value for the enum type. EF Core has a documented behavior where, if a property's value equals the CLR default and the column has a database-level default configured, EF interprets "value equals default" as "caller didn't explicitly set this" and omits the property from the INSERT entirely, deferring to the database's own default instead. It cannot tell the difference between "the caller explicitly chose Pending" and "the caller didn't set anything." Every explicit Pending you wrote was silently dropped before it ever reached SQL Server.
How it was found: Bypassing every layer one at a time — first confirming the deployed commit matched the source (it did), then hitting the endpoint directly through Swagger to rule out the frontend, then querying the actual persisted row, then querying sys.default_constraints directly against the Status column. Each step ruled out one layer until only the database default was left.
Check for this if: Domain code and persisted state disagree, especially when the value in question is a zero-valued enum member, false, 0, null, or empty string — anything that happens to equal a CLR default. Search the relevant IEntityTypeConfiguration<T> for HasDefaultValue(...) before assuming the bug is in the domain layer.
SELECT dc.name, dc.definition
FROM sys.default_constraints dc
JOIN sys.columns c ON dc.parent_object_id = c.object_id AND dc.parent_column_id = c.column_id
WHERE dc.parent_object_id = OBJECT_ID('YourTableName') AND c.name = 'YourColumnName';
2. Dapper has no built-in support for DateOnly parameters
What happened: A Dapper query that took DateOnly values as query parameters threw System.NotSupportedException: The member StartDate of type System.DateOnly cannot be used as a parameter value — but only in production, because it was the first Dapper query in the codebase to ever take a date-range parameter directly rather than just a Guid.
Root cause: Dapper maps parameter types to a registered SqlDbType internally, and DateOnly isn't one of the types it knows about out of the box.
Fix used: Convert to DateTime at the query boundary, right before the parameters object is built:
var results = await conn.QueryAsync<T>(sql, new
{
StartDate = query.StartDate.ToDateTime(TimeOnly.MinValue),
EndDate = query.EndDate.ToDateTime(TimeOnly.MinValue),
});
Better long-term fix, not yet done anywhere: register a global type handler once at startup so every future Dapper query can take DateOnly directly:
SqlMapper.AddTypeHandler(new DateOnlyTypeHandler());
Check for this if: Writing a new raw Dapper query that takes any date value as a parameter. DateOnly compiles fine and only fails at runtime, so this won't show up until the query actually executes.
3. A CORS error in the browser console can be masking a server exception
What happened: The browser console showed Access to XMLHttpRequest ... has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present, alongside a 500 status. It looked like a CORS configuration problem. It wasn't — CORS was never actually misconfigured at any point.
Root cause: When an API throws an unhandled exception, the response can lose its CORS headers entirely — the exception unwinds the request pipeline before the CORS middleware gets a chance to write them. The browser then reports what it sees (no CORS header present) as a CORS block, even though the real problem is a server-side 500 underneath it.
How it was found: Ignoring the CORS message entirely and going straight to Azure App Service's Log Stream to find the actual unhandled exception. In every case this week, the CORS message was a red herring and the real exception was something else entirely (a missing DB column, a Dapper type error, a bad parameter binding).
Check for this if: A CORS error appears on an endpoint that has worked before, especially paired with a 500 status. Go to Log Stream first, always — don't spend time re-checking CORS configuration until the actual exception has been ruled out.
az webapp log tail --name <app-name> --resource-group <resource-group>
4. A bodyless POST can 415 for a reason that has nothing to do with the endpoint
What happened: Approve and Cancel endpoints — structurally identical to Deny, which worked fine — returned 415 Unsupported Media Type with an empty response body, both from the running frontend and when reproduced through Swagger.
Root cause: The frontend called apiClient.post(url) with no second argument. Axios's behavior when no data argument is passed is to send no body and no Content-Type header at all — not even an empty one. FastEndpoints' model binding, seeing a request DTO with bindable properties but no indication of what content type (if any) is coming, rejects the request outright as 415 before it ever reaches handler code. Deny worked because it always sent a real JSON body ({ reason }), which gave axios a reason to set Content-Type: application/json automatically.
Fix used: Explicitly pass an empty object as the body on any bodyless action call:
await apiClient.post(`/v1/employees/${id}/leave-requests/${requestId}/approve`, {})
Check for this if: Any new "action" endpoint (approve/deny/cancel/activate/deactivate-style, no real payload) returns 415 with an empty response body. Check whether the frontend call actually passes a body — even an empty {} — rather than assuming the bug is in endpoint configuration.
5. A new product scaffolded from an existing one can silently inherit an incomplete copy
What happened: Building WAY-18 (cross-aggregate domain event handling) required real event dispatch for the first time in Waypoint's life. It didn't exist. Every domain event raised since WAY-16 — across two already-shipped features — had been silently captured into each aggregate's in-memory event list and then discarded on every save, because nothing ever read that list back out and dispatched it.
Root cause: Waypoint's Waypoint.API.SharedKernel was scaffolded from RoundTrip's pattern, but RoundTrip's complete domain-event pipeline (IDomainEvent, IDomainEventHandler<T>, IDomainEventDispatcher, MediatorDomainEventDispatcher, and the collect/dispatch/clear step inside UnitOfWork.SaveChangesAsync) never made the trip. Waypoint's AggregateRoot had only a bare RaiseDomainEvent(object) with nowhere for those objects to go. This was invisible for two full features because nothing needed cross-aggregate side effects until WAY-18 — the gap cost nothing until the exact moment it would have cost everything.
How it was found: This is the one pattern here that was caught before it caused a production incident — deliberately, because of prior history with a near-identical class of bug in RoundTrip (Mediator silently not firing, discovered only after significant chase time). Before building anything on top of the ported dispatch pipeline, a single throwaway logging handler was wired up and verified in Log Stream against a real deployed action, confirming the entire chain — raise → collect → save → dispatch → handle — actually fired end to end before trusting it with real logic.
Check for this if: Scaffolding any new product from an existing one. Do a deliberate, explicit file-by-file parity check of SharedKernel against the most mature existing product's version — don't assume a scaffold that compiles and runs is a scaffold that's complete. Absence of errors is not evidence of completeness for infrastructure that's silently inert until something needs it.
Structural fix in progress: TRA-357 — a dotnet new template for scaffolding new Traxs product APIs, specifically so this stops being a manual copy-and-hope process.
6. EF Core warns about unconfigured decimal columns at startup — and it's easy to never see it
What happened: Two decimal properties (LeaveRequest.HoursRequested, PtoAdjustment.Hours) were added to their respective entities without an explicit HasColumnType(...) or HasPrecision(...) in their EF configuration. Nothing broke — SQL Server's implicit default precision happened to be wide enough for the actual values in play — but the warning had been present in every single app startup log since the migrations that added those columns, and went unnoticed for several days simply because nobody was actively watching Log Stream at startup.
[WRN] No store type was specified for the decimal property 'HoursRequested' on entity type 'LeaveRequest'. This will cause values to be silently truncated if they do not fit in the default precision and scale.
Root cause: EF Core's model validation checks every decimal property for an explicit column type at startup and warns if one isn't set, because the fallback precision can silently truncate values that don't fit. This class of bug is worse than a hard failure — a hard failure gets noticed immediately; silent truncation waits until a value happens to be large enough to matter.
Check for this if: Adding any new decimal property to an entity. Give it an explicit HasColumnType("decimal(x,y)") matching whatever related columns already use (e.g. match PtoAdjustment.Hours to Employee.PtoAccrued's existing decimal(6,2), don't just let EF pick something) — and periodically scan Log Stream at startup for [WRN] lines, not just [ERR]/[FTL].
The common thread
Every pattern above was invisible to manual testing until the exact code path that exposed it happened to run. None of them would have survived a real test suite:
- Pattern 1 (silent DB default) — an integration test asserting
LeaveRequest.Create()produces aPendingstatus against a real database, not an in-memory provider, would have failed on day one. - Pattern 2 (Dapper
DateOnly) — any integration test exercising the query with real parameters would have caught it immediately. - Pattern 5 (missing dispatch pipeline) — an architecture test asserting "every
IDomainEventhas at least one registered handler, and dispatch actually reaches it" would have caught the gap the moment it was scaffolded, not weeks later under time pressure.
This is the direct argument for WAY-39 — automated testing isn't abstract hygiene, it's specifically the tool that turns "invisible until you happen to hit it" into "caught before merge." Treat this document as the input to that ticket's test plan, not a replacement for it.