How do you implement global exception handling and structured logging in a production ASP.NET Core API?
8 reports in the question index: Infosys (3), TCS (2), Deloitte, Accenture and Corporate Infotech, in first technical rounds, September and October 2026.
Say this first · about 30 seconds
I handle exceptions in one place, near the start of the middleware pipeline: UseExceptionHandler with an IExceptionHandler class, plus AddProblemDetails. Anything thrown further in, by a controller, a service or a filter, ends up there, as long as the response hasn't started yet.
The handler logs the full exception with its stack trace, and the client gets a 500 problem details body with a safe title, the trace id, which ASP.NET Core makes for the request, and our correlation id, which the portal sends. Never the exception message, because messages can carry SQL, server names or customer data.
Expected failures aren't logged as errors: a validation failure is a 400 and a missing record is a 404. I log with message templates, never string interpolation, and a scope puts the correlation id on every line.
Make it yours · Navistar Financial credit submissions (invented example)
Situation: our API sends each truck loan application to the FIS core for a credit decision. Some come back rejected for bad data, like an invalid (the vehicle's 17-character serial number). We logged each one as plain text, "Submission failed for dealer 2231: _INVALID", so nobody could count rejections per dealer.
What I did: changed the log call to a message template with named fields, {DealerId} and {Reason}. In each one becomes its own column, so one query groups rejections by dealer and reason. I pinned that query to the team's dashboard.
Result: on the first day the dashboard showed one dealer behind 41 of 60 rejections: their own system added a space to every . called them with the numbers, and rejections dropped by two thirds.
What I'd say: structured logging turns logs into data you can query. The template stays the same for every call, and the values are separate fields.
The code, simplified
Invented to show the shape, in C#. Tap Next: the FIS core rejects application 77 from dealer 2231.
Navistar.sln · where these 2 files sit
Services/
SubmissionService.cs · line 2 · 1 of 7 and let the method wait for the FIS core without blocking a thread; is the "done later" wrapper ().
Fix later in your repoExample written for this card: invented, not from a real codebase.
Say it out loud, then check
Show the 30-second answer
I handle exceptions in one place, near the start of the middleware pipeline: UseExceptionHandler with an IExceptionHandler class, plus AddProblemDetails. Anything thrown further in, by a controller, a service or a filter, ends up there, as long as the response hasn't started yet.
The handler logs the full exception with its stack trace, and the client gets a 500 problem details body with a safe title, the trace id, which ASP.NET Core makes for the request, and our correlation id, which the portal sends. Never the exception message, because messages can carry SQL, server names or customer data.
Expected failures aren't logged as errors: a validation failure is a 400 and a missing record is a 404. I log with message templates, never string interpolation, and a scope puts the correlation id on every line.
Check your answer: tick what you said
You said 0 of 8
Then add your example: Navistar Financial credit submissions (invented example)
Situation: our API sends each truck loan application to the FIS core for a credit decision. Some come back rejected for bad data, like an invalid (the vehicle's 17-character serial number). We logged each one as plain text, "Submission failed for dealer 2231: _INVALID", so nobody could count rejections per dealer.
What I did: changed the log call to a message template with named fields, {DealerId} and {Reason}. In each one becomes its own column, so one query groups rejections by dealer and reason. I pinned that query to the team's dashboard.
Result: on the first day the dashboard showed one dealer behind 41 of 60 rejections: their own system added a space to every . called them with the numbers, and rejections dropped by two thirds.
What I'd say: structured logging turns logs into data you can query. The template stays the same for every call, and the values are separate fields.
See it
PortalUDC
Corr. ID1st
Handler2nd
Codecontroller
GET statement, c-81f2
1/8A UDC customer opens a statement. The portal sends its correlation id, c-81f2, in a header.Middleware 1 opens a scope that stamps c-81f2 on each log line inside it, then passes the request.The controller logs "Loading statement for 7": = 7 and c-81f2 are separate fields.The FIS core (the lender's main loan system) is too slow: a . Nothing catches it.The exception travels back up to , which asks the registered handlers in order.A handler for locked contracts says "not mine". The global one logs the error, stack and c-81f2.The portal gets a 500 ProblemDetails: a safe title and c-81f2. No exception message, no server name.The customer reads out c-81f2. Support searches for it and sees the whole request: two lines.
In plain words
Exception: when code hits a problem it can't handle, like a timeout or a null, it throws an exception. The exception travels back up through the callers until something catches it with try/catch. If nothing catches it, it is unhandled.
Global exception handler: one piece of code that catches every unhandled exception in the API, so you don't write try/catch in every action. In ASP.NET Core it is the middleware , placed near the start of the so it wraps everything after it.
IExceptionHandler: an (since .NET 8) for the class that decides what to do with the exception. Its one method, , logs it and writes the response. It returns true when it handled the exception.
ProblemDetails: the standard JSON shape for an error (). The client gets a short title and an id, never the exception's own message.
Several handlers: you can register more than one. They're asked in the order you registered them; the first one that returns true wins. A specific one ("contract locked": the nightly batch has locked it → 409) goes before the catch-all (anything → 500).
Response already started: once the first bytes of the response have gone to the client, the status code can't change any more. An exception after that point can't become a ProblemDetails; the client just gets a cut-off response.
Logging: writing lines about what the app is doing, through (T is the class doing the logging), to places like the console or (Azure's monitoring service, where we search logs).
Structured logging: the message is a template with named holes, "Loading statement for {ContractId}", and the values are passed separately. The text still reads normally, and each value is also stored as its own field you can search and count.
Log level: how serious a line is: Trace, Debug, Information, Warning, Error, Critical. Settings decide which levels are kept, per part of the app.
Correlation id: one id per request, on every log line and in the error the user sees (). A logging scope adds it to every line written inside it.
The senior answer · about 2 minutes
One place for unhandled errors
Every API I've worked on has one global handler: near the start of the pipeline, , and since .NET 8 an class. It catches whatever a controller, service or filter throws, logs it once with the full stack trace, and answers a 500 ProblemDetails. Try/catch in actions is only for errors the action can actually recover from.
What the client sees, and what it doesn't
The client gets a short title, the trace id (ASP.NET Core's own id for the request) and our correlation id (the one the portal sends). It never gets the exception message: at UDC the message, with the SQL server's name in it, went into every 500 for a week, the worst during a database failover ( switching to a standby), which is the incident in Deep. Support asks for the id on the screen and finds every log line of that request in one query.
Expected errors aren't 500s
Bad input is a 400 from [ApiController], a missing record is a 404 return value. Known business exceptions, like while the FIS core runs its nightly batch, get a small handler registered before the global one: it answers 409 with "try again after 3 am" and logs a warning, so it doesn't page anyone at night.
Logs you can query
I log with message templates, "Loading statement for {ContractId}", never $"...", so is a field in and I can find every request for one contract. A correlation-id middleware opens a logging scope before the exception handler, so even the error line carries the id. Levels are set per category in appsettings, Information for our code and Warning for Microsoft's in production, and an alert fires on the rate of 500s, not on single lines.
One failing request, in the logs
What holds a minute after the error, cut down to fit a phone. union traces, exceptions reads both tables at once: log lines land in traces, logged exceptions in exceptions. Levels are numbers: Information 1, Warning 2, Error 3.
traces + exceptions
Lvl
Message
Corr
Contract
1
Loading 12
c-77aa
12
1
Loading 7
c-81f2
7
1
Loading 7
c-90bd
7
3
Unhandled …/7
c-81f2
2
Locked 8
c-12ce
8
1/5Many requests at once. Five lines from four requests, mixed together, as they really arrive.Correlation id. The screen says c-81f2. One filter gives that request: the load, then the error.Structured field. Every request for contract 7, from anyone: is a field, not text.Log levels. The alert counts only severityLevel 3 (Error). The locked contract is 2: no page.Not in the table: the stack trace is in the error row's details, never in the customer's reply.
The story
Before
UDC Finance's customer API has about thirty actions (controller methods that each answer one kind of request). In the first version, each one wrapped its work in its own try/catch (a block that tries the work and catches an exception, the error object thrown when something fails): it wrote the exception to the log and returned a 500 with some JSON. Every action did it slightly differently.
Statementtry/catch: own JSON
Change daytry/catch: message
PayOffno try/catch
1/4Before: every action catches its own exceptions and answers in its own shape.One sends the exception message to the customer. One has no catch: an empty 500 with no body.Fix: one global handler at the start of the pipeline. The actions drop their try/catch.Every unhandled error is logged once and answered the same safe way, with an id.
Why it had to change
Copied error handling drifts. One action returned the raw exception message to customers; another forgot the catch, so the customer got an empty 500 with nothing to quote to support. The logs were plain sentences, so support couldn't find the lines for one customer's failed request among thousands.
How it works now
ASP.NET Core gives one place for this. A middleware is a step every request passes through, in order; the whole list of steps is the pipeline. The middleware , near the start of the pipeline, catches any exception thrown after it and hands it to your exception handler class. That class logs the full error and answers with a safe ProblemDetails body (the standard JSON error shape). For the logs, structured logging keeps every value as a searchable field, and a correlation id on every line ties one request's lines together. The main diagram follows one failing request through both.
What we gained
Actions only do their own work, every error answers the same safe way, and support goes from the id on the customer's screen to the exact log lines in one search. The rest of the card shows the handler chain, what never to log, and the week UDC's errors showed customers a server name.
In the lending API
The global handler: the one place an unhandled exception becomes a log line and a safe 500.
Only the key lines are shown; Deep walks the whole file.
Lending.sln · where this file sits
src/Udc.Customer.Api/Errors/
⋯ 1 line in Deep
GlobalExceptionHandler.cs · line 1 · 1 of 4The catch-all handler. A primary constructor: DI passes in the ProblemDetails writer (problems) and the logger (log). More: .
The code, file by file
The UDC customer API: start-up, the correlation id, a controller and the global handler. Tap Next to walk one failing request.
Lending.sln · where these 4 files sit
src/Udc.Customer.Api/
Middleware/
Controllers/
Errors/
Diagram · step 7The portal gets a 500 ProblemDetails: a safe title and c-81f2. No exception message, no server name.
PortalUDC
Corr. ID1st
Handler2nd
Codecontroller
GET statement, c-81f2
Program.cs · line 4 · 1 of 7Errors leave as . The lines below add our correlation id to every one. (Set-up sits first in the file, so the walk reaches it last.)
What they ask next
The follow-ups interviews really ask. Answer out loud, then tap to check.
ReportedHow would you create a global exception handler?
In .NET 8 and later: a class that implements , registered with AddExceptionHandler<T>(), plus , and app.UseExceptionHandler() near the start of Program.cs. Its logs the exception and writes a 500 ProblemDetails. Before .NET 8 the same idea was a custom middleware with try { await next(context); } catch (Exception ex) { ... }, or with an error path.
In the code: one global handler that logs the full error and returns a safe 500 with a correlation id. Around it: collects the logs and requests; an alert fires on the rate of 500s, not on single lines; the person takes the correlation id or time from the ticket, finds the request in one query, and follows the . After a fix, a regression test and a . The incident in Deep walks one end to end.
ReportedHow do you implement error logging in .NET Core?
Through , which DI gives every class. Providers decide where lines go: the console locally, in Azure. Levels are set per category in appsettings.json. Errors are logged with the exception object, LogError(exception, "..."), so the stack trace is kept.
Answer, then open the next level. That is the order an interviewer digs in.
Reporteda candidate was asked Intervieweran interviewer asks it
ReportedHow would you create a global exception handler?
In .NET 8 and later: a class that implements , registered with AddExceptionHandler<T>(), plus , and app.UseExceptionHandler() near the start of Program.cs. Its logs the exception and writes a 500 ProblemDetails. Before .NET 8 the same idea was a custom middleware with try { await next(context); } catch (Exception ex) { ... }, or with an error path.
In the code: one global handler that logs the full error and returns a safe 500 with a correlation id. Around it: collects the logs and requests; an alert fires on the rate of 500s, not on single lines; the person takes the correlation id or time from the ticket, finds the request in one query, and follows the . After a fix, a regression test and a . The incident in Deep walks one end to end.
ReportedHow do you implement error logging in .NET Core?
Through , which DI gives every class. Providers decide where lines go: the console locally, in Azure. Levels are set per category in appsettings.json. Errors are logged with the exception object, LogError(exception, "..."), so the stack trace is kept.
Exception middleware, exception filter or try/catch: which and when?
The global safety net is middleware: it sees everything, including other middleware and writing the response. An exception filter only sees MVC (the action, binding, action filters), so I use one at most to map a domain exception for controllers (). Try/catch belongs where the code can actually do something: retry, fall back, or add context and rethrow.
What happens if the exception is thrown after the response has started?
Too late to change it. The exception handler logs a warning, "The response has already started, the error handler will not be executed", and the connection is cut; in our test on .NET 10 the client got 200 and half a JSON body. It happens with streaming or large responses. Build the whole result before writing, or accept that the client sees a broken response.
Should every layer catch, log and rethrow?
No. The same error ends up in the logs three times, and the stack trace gets harder to read. Log once, at the boundary: the global handler. A layer catches only to add context, throw new FisTimeoutException("...", ex) with the original as the inner exception, or to recover. On .NET 10, when your returns true, the middleware doesn't write its own error line as well. Earlier versions also logged it, so check your version's docs and filter the duplicate if you see two.
What must never go into logs?
Passwords, tokens, API keys, full card or bank account numbers, and personal data you don't need, like names, addresses or tax numbers. Logs are copied widely and kept long. Log ids instead (contract id, customer id), and check what a whole object would log before passing it to a template.
Log levels: what goes where?
Error: something failed that someone should look at. Warning: unexpected but handled, like a rejected submission. Information: the main steps of a request. Debug and Trace: detail for local work, off in production. Each category (usually a class name) can have its own level in appsettings, so Microsoft's chatty lines stay at Warning.
Production bugs, and how to tell them
Each one: what you see, why, the fix in code, and the line to say.
Every 500 lost its correlation id
HTTP/1.1 500 Internal Server Error
(no X-Correlation-ID header)
union traces, exceptions
| where customDimensions.CorrelationId == "c-81f2"
→ 1 row: "Loading statement for 7"
(the error line is missing)
Cause
Two small mistakes. The correlation middleware was registered after , so by the time the handler logged the error, the scope with the id had already closed: the error line had no . And the middleware set the response header straight away; on an error, the exception handler clears the response, headers included, before it writes the 500, so the header was gone. (The query uses union traces, exceptions because keeps log lines in traces and logged exceptions in exceptions.)
Fix
Register the correlation middleware before , and set the header inside , which runs just before the response is sent, after the handler has cleared it. Both checked on .NET 10: header and error line both carry c-81f2.
Diagram · step 1Before: exception handler first, correlation second. It sets the header and opens its scope.
Handler1st
Corr. ID2nd
Code
Logs
next
header + scope
Before · Program.cs · line 1 · 1 of 2First, so it sits outside the correlation middleware.
Say it
The correlation middleware must wrap the exception handler, or the error is logged outside its scope, and the header must be set in OnStarting, because the exception handler clears the response before writing the 500.
Logs couldn't be searched by contract
traces
| where customDimensions.ContractId == "7"
→ 0 rows
(but "Loading statement for 7" is right there in message)
Cause
A developer wrote the log call with string interpolation, $"Loading statement for {id}". C# builds the finished sentence before sees it, so the template is different for every contract and there is no field: only text. Checked on .NET 10: the interpolated line has no in its fields; the template version has it.
Fix
Use a message template with a named hole and pass the value separately. A built-in code analysis rule flags a log message that isn't a fixed template; we turned it into a build warning.
Before · StatementsController.cs · line 2 · 1 of 1$"..." builds the text first. The logger gets "Loading statement for 7" and nothing else.
Say it
With interpolation the logger only receives finished text, so the value isn't a field. A message template keeps the text and stores ContractId as its own searchable field.
Production incident: Error responses showed customers the database server's name
P2 · serious, has a workaroundUDC Financecame with a deployCustomer portal API (ASP.NET Core on App Service)
Composed, not a real event at UDC Finance. The mechanism is real ASP.NET Core, checked by a run on .NET 10: a handler that sets ProblemDetails.Detail to exception.Message sends that message to the client in every environment, and a handler that sets only a title sends no exception text. Times, counts, names, ticket and PR numbers are composed.
The story in 5 lines
On Tue 13 Oct UDC's yearly penetration test (an outside firm attacking the system on purpose, with permission) reported that the portal's 500 errors showed the SQL server's name and the app's database login.
Release 3.8 on Tue 6 Oct had replaced copied try/catch blocks with one global exception handler. The handler put exception.Message into the response's detail field.
In that week 1,240 responses were 500s; 310 of them, during a database failover ( switching to a standby server) on Fri 9 Oct, carried "Login failed for user 'udc_portal_app'" and the server name. No customer data or passwords were in them.
An the same afternoon removed the line. The fix added a test that fails if any exception text reaches a response, and the database login moved to a , so there is no SQL password left to guess.
The rule since: error responses carry only text we wrote and the ids; the review checklist asks what reaches the client on an error.
Who's who (5 people)
You
.NET developer on the vendor team (L3). Owns the fix.
Tech lead
Vendor senior. Runs the bridge call, reviews every PR.
L2 pod
Vendor support. Runs queries, deploys with approval.
UDC security analyst
Owns the yearly penetration test and its findings.
UDC IT change manager
Approves the emergency change.
Tap a step. The map lights the place where it happens.
Customer portalshows the error
Customer APIException handler
Pen testersoutside firm
Azure SQLudc-sql-prod
You + tech leadL3: our code
App Insightsrequests, exceptions
Azure DevOpsPR, tests, release
Staging slotthe next build waits
1/8 Detect
The pen test report: 500 responses show the SQL server's name and login.
Tue 13 Oct, 10:00 NZDT, New Zealand (02:30 IST)UDC security analyst, then the L2 pod
What: the yearly penetration test report has a new finding: "information disclosure". When the testers forced errors, the portal's 500 responses contained the SQL server's name and the database login the app uses.
How we saw it: the testers attached a screenshot of the portal's error box showing "Login failed for user 'udc_portal_app'". The security analyst raises it with the vendor team.
Why it hid: customers rarely see 500s, and when they did, the box looked like a normal error. Nobody had looked at what the new handler sent.
In theory
Error responses show only text the team wrote, plus an id.
In practice
For a week every 500 carried the raw exception message. An outside test found it, not our own checks.
Try it yourself · 10 minutes, on your Mac
dotnet new web -n ErrLab. Add , an and app.UseExceptionHandler().
In the handler set Detail = exception.Message, run it with ASPNETCORE_ENVIRONMENT=Production and call /boom: the server name is in the body.
Remove that line and call it again: only the title and traceId. The message is still in the console log.
You should see
Whatever the handler puts in the body goes to the client, in every environment. Messages belong in the logs; the client gets text you wrote and an id.
The senior move
"An error response carries only text we wrote and an id; the details live in the logs, and a test proves it."
SourcesSourced: ASP.NET Core docs, "Handle errors in ASP.NET Core" (,, showing exception details only in Development); OWASP's guidance that error messages must not reveal internal details. Checked with a real run on .NET 10: Detail = exception.Message put the message in a Production 500 body; with only a title, the body held no exception text. Composed: UDC's systems, the pen test, dates, counts, names, ticket and numbers and the .
Traps
Don't sayReturn exception.Message so the client knows what went wrong.SayReturn a safe title and the correlation id. The message stays in the logs: it can hold server names, SQL or customer data.
Don't sayPut a try/catch in every action to be safe.SayOne global handler. Try/catch only where the code can recover or add context.
Don't sayLog with $"Loaded {id}"; it's the same text.SaySame text, but no field. A template keeps {ContractId} searchable.
Don't sayAn exception filter is enough for global error handling.SayIt misses middleware and response writing. The safety net is .
Traps
What should you say instead? Tap to check.
Don't sayReturn exception.Message so the client knows what went wrong.
Say instead
Return a safe title and the correlation id. The message stays in the logs: it can hold server names, SQL or customer data.
Don't sayPut a try/catch in every action to be safe.
Say instead
One global handler. Try/catch only where the code can recover or add context.
Don't sayLog with $"Loaded {id}"; it's the same text.
Say instead
Same text, but no field. A template keeps {ContractId} searchable.
Don't sayAn exception filter is enough for global error handling.
Say instead
It misses middleware and response writing. The safety net is .
Live coding
Easy first, then harder. Set a timer and type it before you look.
Task 1
Reportedeasy · 15 minutes
Write a global exception handler (IExceptionHandler or middleware) that logs the exception and returns a 500 ProblemDetails with a correlation ID.
Which interface does .NET 8 give you for this, and how do you register it?
Where in Program.cs must the middleware go?
Where does the correlation id come from, and how does it reach the response body?
Show a solution
Lending.sln · where these 2 files sit
Diagram · step 7The portal gets a 500 ProblemDetails: a safe title and c-81f2. No exception message, no server name.
PortalUDC
Corr. ID1st
Handler2nd
Codecontroller
GET statement, c-81f2
Program.cs · line 1 · 1 of 3Adds the correlation id to every ProblemDetails.
What the interviewer checks
registered with AddExceptionHandler<T>(), plus and app.UseExceptionHandler() early.
LogError(exception, template, ...): the exception object is passed, with a fixed template.
500 ProblemDetails with a safe title; no exception message.
Correlation id from a header or a new one, in a logging scope, in the body () and the response header.
Correlation middleware before , and can say why.
Task 2
Question index: Mustmedium · 15 minutes
BMW FS contracts are locked while the FIS core runs its nightly batch, and the service throws . Return 409 with a "try again later" ProblemDetails, logged as a warning, without touching the global handler.
Can you register more than one ? In which order are they asked?
What should a handler return for an exception that isn't its type?
Which log level fits an expected, handled situation?
Show a solution
Lending.sln · where these 2 files sit
Errors/
Diagram · step 2The exception handler middleware asks handler 1, the first one registered.
Codeservice
Catchmiddleware
Lockedhandler 1
Globalhandler 2
throws Locked
Program.cs · line 2 · 1 of 2Registered first, so it's asked first.
What the interviewer checks
A second that returns false for any other exception.
Registered before , because the first handler that returns true wins.
409 ProblemDetails with a title and a detail written by us; with the contract id as a field.
Can say why this isn't a 500: nothing is broken, so nobody should be paged.
With AI tools
Interviewers now ask how AI tools change this. Assistants write a global handler in seconds, and the same two problems keep showing up in what they generate: Detail = exception.Message "to help debugging", and log calls built with $"...". I check every generated handler for what reaches the client and every log call for a fixed template. AI also helps on the other side: I paste a correlation id's log lines into an assistant to summarise a long trace, after removing customer data.