Deploy a Task Management API to Azure
Build and deploy a REST API with ASP.NET Core and Azure App Service.
Introduction
30 Second Summary
Every app you interact with relies on an API to process requests and send back structured data. The difference between a junior-looking API and a production-grade one is how it handles errors, validates input, and documents itself.
In this project, you will build a Task Management REST API with ASP.NET Core on .NET 10 and deploy it live to Azure App Service. You'll implement proper error handling, input validation, and OpenAPI documentation that you can share via a live URL on your resume.
What You'll Build
Picture sending a POST request to your live Azure URL, getting back a clean 201 response with the created task, then deliberately triggering an error and watching your exception handler return a structured JSON response instead of a stack trace.
By the end of this project, you'll have:
- A live API on Azure that responds to POST, GET, PUT, and DELETE requests with the correct HTTP status codes for each operation.
- A global exception handler that you can trigger on purpose and watch return a clean JSON error response with a trace ID instead of a stack trace.
- Structured validation errors that tell callers exactly which fields are invalid and why, returned automatically when bad data hits your endpoints.
- Secret Mission: Add filtering and pagination to your GET endpoint so callers can request specific pages of filtered results.
Are there any prerequisites?
This project assumes familiarity with C# and object-oriented programming. Experience with .NET Framework is helpful but not required.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters for the roles you're targeting.
Set Up Your Development Environment
Modern .NET roles in 2026 expect hands-on experience with .NET 10 patterns that don't exist in .NET Framework. The project structure, hosting model, and dependency injection approach have changed significantly from what you know in Visual Studio 2019.
Before building anything, let's confirm your development tools are set up correctly. You need VS Code with C# Dev Kit, the .NET 10 SDK, and Azure CLI for deployment later.
In this step, get ready to:
- Confirm VS Code and C# Dev Kit extension are working.
- Verify your .NET 10 SDK installation.
- Verify Azure CLI and your Azure account are ready.
Confirm VS Code and C# Dev Kit
C# Dev Kit gives VS Code rich language features for .NET development. It powers IntelliSense, debugging, test discovery, and project management for your API.
- In VS Code, open the Extensions panel by pressing Cmd+Shift+X.
- Type C# Dev Kit in the search bar.
- Confirm you see C# Dev Kit by Microsoft with a blue Installed badge.
What is C# Dev Kit?
C# Dev Kit is a VS Code extension by Microsoft that brings Visual Studio-level C# tooling into a lightweight editor. It automatically installs the base C# extension and .NET Install Tool as dependencies.
It's free for individuals, academia, and open-source projects. The license terms match Visual Studio Community.
Don't see C# Dev Kit installed?
- Make sure you search for the exact name C# Dev Kit (publisher: Microsoft, ID ms-dotnettools.csdevkit).
- Click Install if the extension is not yet installed. It will also install the C# extension and .NET Install Tool automatically.
- After installation, VS Code may prompt you to sign in to a Visual Studio subscription. Individual use is free.
Help me install C# Dev Kit in VS Code.
Verify your .NET 10 SDK
The .NET 10 SDK is what compiles and runs your API project. Let's confirm it's installed and the correct version is active.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick).
Before you run the next command, what version number do you expect to see?
- Check your .NET SDK version by running:
dotnet --version
You should see a version starting with 10.0 (for example, 10.0.302). This confirms the .NET 10 SDK is your active version.
Seeing a different version or command not found?
- If you see a version lower than 10.0, you have an older SDK installed. Download the .NET 10 SDK from https://dotnet.microsoft.com/en-us/download/dotnet/10.0. Choose the macOS installer (Arm64 for Apple Silicon, x64 for Intel).
- If you see command not found, the SDK is not installed. Download and run the .pkg installer from the same link above.
- After installing, close and reopen your terminal, then run dotnet --version again.
Help me install .NET 10 SDK on macOS.
Verify Azure CLI and your Azure account
Azure CLI is the command-line tool you'll use to deploy your API to Azure App Service in the final step. Let's confirm it's working.
- In your VS Code terminal, check Azure CLI is installed by running:
az version
You should see JSON output listing the Azure CLI version and its extensions. Any version that returns successfully means your CLI is ready.
Command not found for az?
- Install Azure CLI using Homebrew by running brew update && brew install azure-cli in your terminal.
- After installation completes, run az version again to confirm.
Help me install Azure CLI on macOS.
- Confirm your Azure free account is active by visiting https://azure.microsoft.com/en-us/pricing/purchase-options/azure-account.
What does the Azure free account include?
New Azure accounts get $200 credit for 30 days. After the credit expires, the App Service F1 Free tier is always free with no time limit.
Your API will run on this F1 tier. A credit card is required for verification during sign-up, but you won't be charged for this project.
Your development environment is confirmed and ready. Next up, you'll scaffold your first ASP.NET Core API and see how different the modern project structure is from .NET Framework.
Scaffold and Run a Starter API
Your development environment is ready. Now it's time to see the modern .NET 10 project structure in action.
Before writing any custom code, you'll scaffold and run a starter API. This gives you the "baby version" of your end product and shows how different the new hosting model is from .NET Framework's Startup.cs pattern.
In this step, get ready to:
- Scaffold a new .NET 10 Web API project with the controllers template.
- Run the project and see live JSON in your browser.
- Identify how Program.cs replaces the .NET Framework startup pattern.
Create the project with controllers
The dotnet new webapi command without a -controllers flag creates a minimal API project by default in .NET 10. You need the controller-based template because it matches the enterprise pattern hiring managers expect.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create a project folder and move into it by running these commands:
mkdir TaskApi
cd TaskApi
- Scaffold the Web API project by running:
dotnet new webapi -controllers -n TaskApi
You should see a success message confirming the template was created, followed by a restore step.
- Confirm the project files were created by running:
ls TaskApi
You should see Controllers/, Program.cs, TaskApi.csproj, WeatherForecast.cs, and configuration files in the output.
What did that command do?
The -controllers flag tells the template engine to generate a controller-based project instead of a minimal API. The -n TaskApi flag names the project and creates a subfolder with that name.
Inside that subfolder, you now have a fully runnable Web API with a sample WeatherForecast endpoint. No additional configuration needed.
Why controllers over minimal APIs?
Minimal APIs are great for small microservices, but controllers are the dominant pattern in enterprise job postings. With your .NET Framework background, controllers will feel familiar and demonstrate the architecture interviewers expect for multi-entity APIs.
- Open the project folder in VS Code by running:
code TaskApi
Command not found when running code?
On macOS, the code command needs to be added to your PATH manually. Switch to VS Code, press Cmd+Shift+P to open the Command Palette, type shell command, and select Shell Command: Install 'code' command in PATH. Restart your terminal, then try code TaskApi again.
Alternatively, open the folder from within VS Code: click File in the menu bar, select Open Folder, and navigate to Desktop > TaskApi > TaskApi.
Why can't I open the TaskApi folder in VS Code?
VS Code opens with the TaskApi project files visible in the Explorer sidebar.
Run the starter project
The scaffolded project includes a sample WeatherForecast endpoint that returns random weather data. Running it proves the template works before you add any custom code.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, top-left of keyboard).
- Run the project by entering:
dotnet run
The terminal displays build output followed by URLs where the app is listening. Look for lines like:
Now listening on: http://localhost:5xxx
Application started. Press Ctrl+C to shut down.
What do these URLs mean?
The port numbers are assigned by your launchSettings.json file. You may see an HTTP port (like 5000-range) and an HTTPS port (like 7000-range). Either one works for testing.
Before you open the browser, what do you think you'll see at the /weatherforecast endpoint? A form? HTML? Something else?
- Copy the HTTP URL from your terminal output (the one starting with http://localhost:).
- Open a browser and paste the URL, appending /weatherforecast to the end.
You see a JSON array of weather forecast objects with dates, temperatures, and summaries. This is your API responding with real data.
Trust the HTTPS certificate
If your browser shows a certificate warning when using the HTTPS URL, switch to the HTTP URL instead. Alternatively, run dotnet dev-certs https --trust in a separate terminal to trust the development certificate.
- Return to the VS Code terminal and press Ctrl+C to stop the app.
Not seeing JSON in the browser?
Make sure you copied the correct port from the terminal output. The URL must include /weatherforecast at the end.
If the terminal shows a build error, check that your dotnet --version returns a version starting with 10. and that you're running the command from inside the TaskApi project folder (not the parent).
Help me debug my dotnet run issue
Examine the scaffold structure
Now that the project runs, take a look at how .NET 10 structures a Web API. If you're used to .NET Framework, the differences are significant.
- In the VS Code Explorer sidebar, click on Program.cs to open it.
Your Program.cs should look like this:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
What happened to Startup.cs?
In .NET Framework and early .NET Core, you had a Startup.cs class with ConfigureServices() and Configure() methods, plus a Global.asax and web.config. All of that is gone.
.NET 10 uses top-level statements. The entire app startup fits in one file with a builder pattern. builder.Services registers dependencies (like the old ConfigureServices), and the app.Use* / app.Map* calls configure the HTTP pipeline (like the old Configure).
- Expand the Controllers folder in the Explorer and click WeatherForecastController.cs to open it.
This controller uses the [ApiController] attribute and derives from ControllerBase. You'll follow this same pattern when you build your Task controller in the next step.
Key files in the scaffold
- Controllers/ contains classes with public methods exposed as HTTP endpoints.
- Program.cs configures services and the HTTP request pipeline. It's the app's entry point.
- TaskApi.csproj holds project configuration metadata (target framework, packages).
- appsettings.json stores configuration values like logging levels.
✔️ Awesome, my Program.cs matches!
Great. Make sure the file is saved and you're ready for the next step.
ⓧ I'd like to double check the full code
Your Program.cs should contain exactly this (no changes from the scaffold):
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
And your TaskApi.csproj should contain:
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
If your files match, you're all set. If the TargetFramework shows something other than net10.0, confirm your SDK version with dotnet --version.
Your starter API is running and you've seen how .NET 10 structures a project. Next up, you'll replace the WeatherForecast sample with a real Task entity backed by Entity Framework Core.
Build the Task API with EF Core
The WeatherForecast sample proved your project template works. But it has no database, no real data model, and no proper CRUD operations.
In this step, you'll replace the sample with a Task entity backed by Entity Framework Core InMemory. By the end, you'll have a working API that supports all HTTP verbs with proper status codes.
In this step, get ready to:
- Create a TaskItem model and database context with EF Core InMemory.
- Build a TasksController with full CRUD endpoints and proper HTTP status codes.
- Test the API locally by creating and retrieving tasks.
Create the model and DbContext
Every API needs a data model and a way to talk to its database. You'll create a TaskItem class that defines what a task looks like, then wire it to an in-memory database using EF Core.
- Install the EF Core InMemory package by running this command in your VS Code terminal:
dotnet add package Microsoft.EntityFrameworkCore.InMemory
You should see output confirming the package was added, ending with a successful restore.
Seeing a restore error?
Make sure you're running the command from inside the TaskApi project folder (the one containing TaskApi.csproj). Help me troubleshoot this NuGet package install error.
- Create a new folder called Models inside your TaskApi project folder.
- Inside Models, create a new file called TaskItem.cs and paste this code:
namespace TaskApi.Models;
public class TaskItem
{
public int Id { get; set; }
public string Title { get; set; } = string.Empty;
public string? Description { get; set; }
public bool IsCompleted { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
What does this code do?
This defines the shape of a task in your system. Each task gets an auto-incremented Id, a required Title, an optional Description, a completion flag, and a creation timestamp.
- Create a new folder called Data inside your TaskApi project folder.
- Inside Data, create a new file called TaskDbContext.cs and paste this code:
using Microsoft.EntityFrameworkCore;
using TaskApi.Models;
namespace TaskApi.Data;
public class TaskDbContext : DbContext
{
public TaskDbContext(DbContextOptions<TaskDbContext> options) : base(options) { }
public DbSet<TaskItem> Tasks => Set<TaskItem>();
}
What does this code do?
The DbContext is EF Core's bridge between your C# classes and the database. The DbSet<TaskItem> property tells EF Core that you have a "Tasks" table containing TaskItem rows.
- Verify your model and context compile by running:
dotnet build
You should see Build succeeded with 0 errors.
Build failed?
Check that TaskItem.cs is inside a Models folder and TaskDbContext.cs is inside a Data folder. The namespaces in the code must match the folder structure.
Help me fix this build error in my TaskApi project.
Now you need to register the database context in your app's service container so ASP.NET Core can inject it into your controllers.
- Open Program.cs and replace its entire contents with this updated version:
using Microsoft.EntityFrameworkCore;
using TaskApi.Data;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
// Register the in-memory database for task storage
builder.Services.AddDbContext<TaskDbContext>(opt =>
opt.UseInMemoryDatabase("TaskDb"));
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
What changed here?
- Two using statements at the top import EF Core and your Data namespace.
- AddDbContext<TaskDbContext> registers your context with dependency injection, configured to use an in-memory database named "TaskDb".
Why an in-memory database?
EF Core InMemory gives you a real database-like API without installing SQL Server or SQLite. The data resets when the app restarts, which is perfect for a portfolio demo and keeps this project zero-cost.
- Save Program.cs.
- Create a new file Models/ErrorResponse.cs with this code:
namespace TaskApi.Models;
public class ErrorResponse
{
public int Status { get; set; }
public string Message { get; set; } = string.Empty;
public string? TraceId { get; set; }
}
What does this code do?
This defines a consistent shape for error responses. Instead of returning raw strings or stack traces, your API will always return a JSON object with a status code, message, and trace ID. This is the professional pattern interviewers expect.
- Save ErrorResponse.cs and run a build to confirm everything compiles:
dotnet build
You should see Build succeeded again. Your model layer is ready.
Create the TasksController with CRUD endpoints
With your data model in place, you need DTOs (Data Transfer Objects) that define what data the API accepts for creating and updating tasks. Then you'll build the controller that handles all the HTTP requests.
- Create a new file Models/CreateTaskDto.cs and paste this code:
using System.ComponentModel.DataAnnotations;
namespace TaskApi.Models;
public class CreateTaskDto
{
[Required(ErrorMessage = "Title is required")]
[MaxLength(200, ErrorMessage = "Title cannot exceed 200 characters")]
public string Title { get; set; } = string.Empty;
[MaxLength(1000, ErrorMessage = "Description cannot exceed 1000 characters")]
public string? Description { get; set; }
}
What does this code do?
A DTO separates what the client sends from what's stored internally. The [Required] and [MaxLength] attributes enforce validation rules. ASP.NET Core will automatically reject invalid input before your code even runs.
- Save CreateTaskDto.cs.
- Create another file Models/UpdateTaskDto.cs and paste this code:
using System.ComponentModel.DataAnnotations;
namespace TaskApi.Models;
public class UpdateTaskDto
{
[Required(ErrorMessage = "Title is required")]
[MaxLength(200, ErrorMessage = "Title cannot exceed 200 characters")]
public string Title { get; set; } = string.Empty;
[MaxLength(1000, ErrorMessage = "Description cannot exceed 1000 characters")]
public string? Description { get; set; }
public bool IsCompleted { get; set; }
}
What does this code do?
The update DTO includes IsCompleted so clients can mark a task as done. The create DTO doesn't need this because new tasks always start incomplete.
- Save UpdateTaskDto.cs and confirm everything still compiles:
dotnet build
You should see Build succeeded. Your models are all in place.
Now it's time to remove the sample code and build the real controller. The WeatherForecast files are no longer needed.
- Delete the old sample files by running these commands:
rm Controllers/WeatherForecastController.cs
rm WeatherForecast.cs
- Create the new file Controllers/TasksController.cs and paste this initial structure with the GET endpoints:
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using TaskApi.Data;
using TaskApi.Models;
namespace TaskApi.Controllers;
[ApiController]
[Route("api/[controller]")]
public class TasksController : ControllerBase
{
private readonly TaskDbContext _context;
public TasksController(TaskDbContext context)
{
_context = context;
}
// GET: api/tasks
[HttpGet]
public async Task<ActionResult<IEnumerable<TaskItem>>> GetTasks()
{
return await _context.Tasks.ToListAsync();
}
}
What does this code do?
- [ApiController] enables automatic model validation and binding.
- [Route("api/[controller]")] maps this controller to /api/tasks (derived from the class name).
- The constructor receives TaskDbContext via dependency injection. ASP.NET Core handles this automatically because you registered it in Program.cs.
- Save TasksController.cs and run the app to confirm it starts:
dotnet run
You should see the app start and display a URL like http://localhost:5xxx. The GET endpoint is live, but there are no tasks yet so it will return an empty array.
- Stop the app with Ctrl+C.
- Now add the GET-by-ID endpoint. In TasksController.cs, add this method inside the class, below the GetTasks method (above the closing }):
// GET: api/tasks/{id}
[HttpGet("{id}")]
public async Task<ActionResult<TaskItem>> GetTask(int id)
{
var task = await _context.Tasks.FindAsync(id);
if (task is null)
{
return NotFound(new ErrorResponse
{
Status = 404,
Message = $"Task with ID {id} not found"
});
}
return task;
}
What does this code do?
This returns a single task by its ID. If no task exists with that ID, it returns a structured 404 response using your ErrorResponse model instead of a generic error.
- Save the file.
- Add the POST endpoint below the GetTask method:
// POST: api/tasks
[HttpPost]
public async Task<ActionResult<TaskItem>> CreateTask(CreateTaskDto dto)
{
var task = new TaskItem
{
Title = dto.Title,
Description = dto.Description,
IsCompleted = false,
CreatedAt = DateTime.UtcNow
};
_context.Tasks.Add(task);
await _context.SaveChangesAsync();
return CreatedAtAction(nameof(GetTask), new { id = task.Id }, task);
}
What does this code do?
- It maps the incoming DTO to a new TaskItem entity, saves it, and returns HTTP 201 (Created).
- CreatedAtAction sets the Location header to the URL where the new task can be retrieved. This is REST best practice.
- Save and confirm it still compiles:
dotnet build
You should see Build succeeded. Now add the final CRUD methods.
- Add the PUT endpoint below the CreateTask method:
// PUT: api/tasks/{id}
[HttpPut("{id}")]
public async Task<IActionResult> UpdateTask(int id, UpdateTaskDto dto)
{
var task = await _context.Tasks.FindAsync(id);
if (task is null)
{
return NotFound(new ErrorResponse
{
Status = 404,
Message = $"Task with ID {id} not found"
});
}
task.Title = dto.Title;
task.Description = dto.Description;
task.IsCompleted = dto.IsCompleted;
await _context.SaveChangesAsync();
return NoContent();
}
What does this code do?
This finds an existing task, updates its properties from the DTO, saves changes, and returns HTTP 204 (No Content). The 204 status means "success, nothing to show you" which is the standard response for updates.
- Save the file.
- Add the DELETE endpoint and a test error endpoint below UpdateTask:
// DELETE: api/tasks/{id}
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteTask(int id)
{
var task = await _context.Tasks.FindAsync(id);
if (task is null)
{
return NotFound(new ErrorResponse
{
Status = 404,
Message = $"Task with ID {id} not found"
});
}
_context.Tasks.Remove(task);
await _context.SaveChangesAsync();
return NoContent();
}
// GET: api/tasks/error (test endpoint)
[HttpGet("error")]
public IActionResult TriggerError()
{
throw new InvalidOperationException("This is a deliberate test error to demonstrate exception handling");
}
What does this code do?
- DeleteTask removes the task from the database and returns 204.
- TriggerError deliberately throws an exception. You'll use this in the next step to test your global error handling middleware.
- Save TasksController.cs.
Seeing red squiggles in VS Code?
If the C# Dev Kit shows errors, make sure all the methods are inside the class braces. Also verify that your using statements at the top of the file match: Microsoft.AspNetCore.Mvc, Microsoft.EntityFrameworkCore, TaskApi.Data, and TaskApi.Models.
Help me fix these C# errors in my TasksController.
✔️ Awesome, I've got everything!
Great. Double check you've saved all your files before moving on to testing.
ⓧ I'd like to double check the full code
Your controller was built in chunks. Scroll up and confirm each section is present in order:
- Imports and class declaration with constructor.
- GetTasks (returns all tasks).
- GetTask (returns one task by ID, or 404).
- CreateTask (POST, returns 201).
- UpdateTask (PUT, returns 204).
- DeleteTask (DELETE, returns 204).
- TriggerError (throws exception for testing).
- Closing } brace for the class.
Test the API locally
Your controller is complete. Time to run the app and confirm you can create a task, retrieve it, and get a proper 404 for a non-existent ID.
- Start the app by running:
dotnet run
Note the port number from the console output (e.g. http://localhost:5xxx). You'll use this in the test commands below.
- Open a second terminal in VS Code by clicking the + icon in the terminal panel.
- Create a task by running this curl command (replace 5xxx with your actual port number):
curl -X POST http://localhost:[[PORT="your port number, e.g. 5184"]]/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Learn ASP.NET Core", "description": "Build a REST API"}'
Before you run this, what HTTP status code do you expect back for a successful creation?
You should see a JSON response with HTTP 201 containing the created task, including an auto-generated id of 1 and a createdAt timestamp.
- Retrieve that task by its ID:
curl http://localhost:[[PORT="your port number, e.g. 5184"]]/api/tasks/1
You should see the same task you just created returned as JSON with a 200 status.
- Now request a task that doesn't exist:
curl http://localhost:[[PORT="your port number, e.g. 5184"]]/api/tasks/999
What do you expect to see this time?
You should see your structured 404 response: {"status":404,"message":"Task with ID 999 not found","traceId":null}. No stack trace, no ugly error page. A clean, predictable JSON response.
Getting connection refused?
Make sure your first terminal is still running dotnet run and that you're using the correct port number from its output. Also check that you're using http (not https) in the curl URL.
Help me troubleshoot my curl request to the ASP.NET Core API.
- Stop the app in the first terminal with Ctrl+C.
Your API has full CRUD working with proper HTTP status codes. Next up, you'll add global exception handling so that even unexpected errors return clean, consistent JSON instead of stack traces.
Add Global Exception Handling and Structured Errors
Your Task API handles CRUD operations and returns correct status codes for expected scenarios like a missing task. But what happens when something truly unexpected goes wrong?
Right now, an unhandled exception returns ASP.NET Core's built-in Developer Exception Page with stack traces, headers, and internal details. That's helpful for debugging locally, but it exposes internals you'd never want an API consumer (or an attacker) to see. In this step, you'll build custom middleware that catches every unhandled exception and returns a consistent, structured JSON error response.
In this step, get ready to:
- See how unhandled exceptions behave without custom middleware.
- Create global exception handling middleware that returns structured JSON errors.
- Verify structured error responses for both server errors and validation failures.
See how your API handles errors right now
Your controller already has a TriggerError endpoint that deliberately throws an exception. Before building any fix, you should see the problem firsthand.
- Start your API by running this command in the VS Code terminal:
dotnet run
Note the port number from the console output (e.g., http://localhost:5xxx).
Before you hit the error endpoint, what do you think the response will look like? A clean JSON object, or something messier?
- Open a new terminal tab and trigger the error endpoint by running:
curl http://localhost:5000/api/tasks/error
What should I see?
Since your app runs in the Development environment and curl doesn't send an Accept: text/html header, ASP.NET Core's built-in Developer Exception Page returns a ProblemDetails JSON payload. It includes internal details like the exception type, a full stack trace, and request headers.
This response leaks implementation details. An API consumer learns your framework, your file paths, and your method names. That's the gap you'll close now.
- Stop the running app with Ctrl+C.
Create the exception middleware
ASP.NET Core middleware sits in the request pipeline and can intercept every request and response. Your custom middleware will wrap the entire pipeline in a try/catch, intercept any unhandled exception, and return a uniform JSON error shape.
- Create a Middleware folder in your project by running:
mkdir Middleware
- Create a new file called ExceptionMiddleware.cs inside the Middleware folder.
- Add the class definition, constructor, and main invoke method by pasting this code:
using System.Diagnostics;
using System.Text.Json;
using TaskApi.Models;
namespace TaskApi.Middleware;
// Catches all unhandled exceptions in the request pipeline
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ExceptionMiddleware> _logger;
public ExceptionMiddleware(RequestDelegate next, ILogger<ExceptionMiddleware> logger)
{
_next = next;
_logger = logger;
}
// Wraps the entire pipeline in a try/catch
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception ex)
{
_logger.LogError(ex, "An unhandled exception occurred");
await HandleExceptionAsync(context, ex);
}
}
What does this code do?
- RequestDelegate _next represents the next middleware in the pipeline. Calling await _next(context) passes the request through to controllers and other middleware.
- If anything downstream throws, the catch block logs the error and calls HandleExceptionAsync to build a clean response.
- ILogger ensures the full exception still appears in your server logs for debugging.
- Now add the private helper method that maps exceptions to HTTP status codes. Paste this directly below the InvokeAsync method, inside the same class:
// Maps exception types to HTTP status codes and builds a JSON response
private static async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
var statusCode = exception switch
{
KeyNotFoundException => StatusCodes.Status404NotFound,
ArgumentException => StatusCodes.Status400BadRequest,
_ => StatusCodes.Status500InternalServerError
};
var response = new ErrorResponse
{
Status = statusCode,
Message = statusCode == StatusCodes.Status500InternalServerError
? "An unexpected error occurred"
: exception.Message,
TraceId = Activity.Current?.Id ?? context.TraceIdentifier
};
context.Response.ContentType = "application/json";
context.Response.StatusCode = statusCode;
var json = JsonSerializer.Serialize(response, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
await context.Response.WriteAsync(json);
}
}
What does this code do?
- The switch expression maps known exception types to specific HTTP codes. A KeyNotFoundException becomes a 404, an ArgumentException becomes a 400, and anything else becomes a 500.
- For 500 errors, the response hides the real exception message and returns a generic string. This prevents leaking internal details to API consumers.
- Activity.Current?.Id ?? context.TraceIdentifier gives each error a unique trace ID. Support teams use this to correlate a user's error report with server-side logs.
- Save Middleware/ExceptionMiddleware.cs.
- Confirm the project still compiles by running:
dotnet build
You should see Build succeeded with 0 errors.
Seeing build errors?
- Check that ExceptionMiddleware.cs is inside the Middleware folder, not the project root. The namespace TaskApi.Middleware must match the folder structure.
- Make sure the closing brace } at the end of the second code block closes both the method and the class.
- Verify using TaskApi.Models; is at the top of the file. The ErrorResponse class lives in that namespace.
help me fix my ExceptionMiddleware build error
The middleware exists but isn't connected to the pipeline yet. You need to register it in Program.cs.
- In Program.cs, find the using statements at the top of the file:
using Microsoft.EntityFrameworkCore;
using TaskApi.Data;
- Add using TaskApi.Middleware; so the top now looks like this:
using Microsoft.EntityFrameworkCore;
using TaskApi.Data;
using TaskApi.Middleware;
- Now find this line further down in Program.cs:
var app = builder.Build();
- Add the middleware registration directly below that line, so it reads:
var app = builder.Build();
app.UseMiddleware<ExceptionMiddleware>();
Why register it first?
Middleware runs in the order you register it. Placing ExceptionMiddleware before all other middleware means it wraps the entire pipeline. Any exception thrown by any downstream middleware or controller gets caught here.
- Save Program.cs.
✔️ Awesome, I've got everything!
Great. Double check you've saved both Middleware/ExceptionMiddleware.cs and Program.cs.
ⓧ I'd like to double check the full code
For Middleware/ExceptionMiddleware.cs, scroll up and confirm you have both chunks in order: the class with InvokeAsync (first block), then the HandleExceptionAsync method (second block) closing the class.
For Program.cs, here is the full file:
using Microsoft.EntityFrameworkCore;
using TaskApi.Data;
using TaskApi.Middleware;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddOpenApi();
builder.Services.AddDbContext<TaskDbContext>(opt =>
opt.UseInMemoryDatabase("TaskDb"));
var app = builder.Build();
app.UseMiddleware<ExceptionMiddleware>();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();
app.Run();
Test your structured error responses
Your middleware is wired up. Now you'll hit the same error endpoint again and see the difference.
- Start the app again by running:
dotnet run
What do you think the error endpoint will return now? The same noisy response, or something cleaner?
- Hit the error endpoint again by running:
curl http://localhost:5000/api/tasks/error
You should see a clean JSON response like this:
{
"status": 500,
"message": "An unexpected error occurred",
"traceId": "00-abc123..."
}
What just changed?
Instead of leaking stack traces, file paths, and framework internals, the response now contains exactly three fields: a status code, a safe generic message, and a trace ID for log correlation. This is the exact response shape you'd troubleshoot in a production support role.
Now test input validation. The [ApiController] attribute on your controller automatically returns a 400 response when model validation fails.
- Send a POST request with an empty body by running:
curl -X POST http://localhost:5000/api/tasks -H "Content-Type: application/json" -d "{}"
You should see a structured 400 response with field-level validation errors, including a message that Title is required.
Why does validation work without custom code?
The [ApiController] attribute enables automatic model validation. When the request body doesn't satisfy the [Required] and [MaxLength] attributes on CreateTaskDto, ASP.NET Core short-circuits the request and returns a 400 before your controller code ever runs.
- Stop the app with Ctrl+C.
Not seeing the expected JSON responses?
- Make sure app.UseMiddleware<ExceptionMiddleware>() appears before app.UseHttpsRedirection() in Program.cs. If it's after other middleware, exceptions might be caught elsewhere first.
- Check that the port in your curl command matches the port shown in the dotnet run output. It might be 5000, 5001, or another number.
- If you see connection refused, make sure you're using http:// not https:// for the non-TLS port.
help me troubleshoot my exception middleware not returning JSON
Your API now handles errors like a production system. Every unhandled exception returns a consistent shape that's safe to expose publicly, with enough information to trace issues in logs. Next up, you'll deploy this live to Azure so anyone can hit your endpoints from a real URL.
Deploy to Azure App Service
Your API works locally, but a project that only runs on localhost proves nothing to a hiring manager.
Deploying to Azure App Service gives you a live URL you can put on your resume. It also teaches you the deployment workflow that Azure-focused roles expect.
In this step, get ready to:
- Deploy your API to Azure with a single CLI command.
- Test your live API endpoints and exception middleware in production.
- Enable and verify OpenAPI documentation at your live URL.
Log in to Azure CLI
Before you can deploy anything, you need to authenticate the Azure CLI with your Azure account.
- Sign in to Azure by running this command:
az login
What does this command do?
This opens your default browser to the Azure sign-in page. Once you authenticate, the CLI stores your credentials so subsequent commands can create and manage resources in your account.
- Your browser opens an Azure sign-in page. Sign in with the account you used to create your Azure free account.
- After signing in, return to your terminal. You should see a JSON response listing your subscription details (subscription name, ID, and state).
Browser didn't open?
If the browser doesn't open automatically, look for a URL and a device code in the terminal output. Copy the URL into your browser and enter the code manually.
If you see an error about no subscriptions, make sure you completed the Azure free account sign-up at https://azure.microsoft.com/en-us/pricing/purchase-options/azure-account.
Help me troubleshoot my Azure CLI login
Deploy to Azure with a single command
Azure CLI has a command that creates your resource group, App Service plan, and web app all at once. It then deploys your code from the current directory.
- Make sure your terminal is in the TaskApi project folder (the one containing TaskApi.csproj).
- Deploy your app by running this command:
az webapp up --sku F1 --name [[APPNAME="your-unique-app-name"]] --os-type linux
What does this command do?
- --sku F1 selects the Free tier App Service plan. This costs $0 and has no time limit.
- --name sets your app's public URL. This must be globally unique across all of Azure, so include your name or initials (e.g. taskapi-jsmith).
- --os-type linux deploys to a Linux host, which supports .NET build automation (dotnet restore and dotnet publish run on the server).
Behind the scenes, Azure CLI creates a Resource Group, an App Service Plan, and a Web App in one shot.
Deployment takes 1-2 minutes. Wait until you see a JSON response in your terminal confirming the deployment succeeded.
- Note the resourceGroup value in the JSON output. You will need this name in a later command.
What resource group name was created?
The az webapp up command auto-generates a resource group name, typically following a pattern like appsvc_rg_Linux_CentralUS (based on your OS type and region). Copy the exact name from your terminal output.
Seeing a naming conflict?
If you see an error that the name is already taken, pick a more unique name and run the command again. App names must be globally unique across all Azure customers.
Help me fix my Azure deployment error
Test the live API and verify exception handling
Your API is now running on Azure. Before you look at the result, think about what state the database starts in on a fresh deployment.
- Open your browser and navigate to your live tasks endpoint:
https://[[APPNAME="your-unique-app-name"]].azurewebsites.net/api/tasks
What do you expect to see? The InMemory database starts empty on every deployment, so you should see an empty JSON array: [].
- Create a task on your live API by running this curl command:
curl -X POST https://[[APPNAME="your-unique-app-name"]].azurewebsites.net/api/tasks \
-H "Content-Type: application/json" \
-d '{"title": "My first Azure task", "description": "Deployed from the CLI"}'
- You should see a 201 Created response with your task's JSON, including an id field and a createdAt timestamp.
- Refresh /api/tasks in your browser. You should now see your task in the array.
Now test your exception middleware in production. Do you think it will return a clean JSON error or a raw stack trace?
- Open this URL in your browser:
https://[[APPNAME="your-unique-app-name"]].azurewebsites.net/api/tasks/error
You see a clean JSON 500 response with your structured error shape. The message field says An unexpected error occurred and a traceId is included. No stack trace is leaked.
Why this matters in interviews
This is the exact scenario you'd diagnose in a tech support or SRE role. The API is returning 500s, and you need a structured response body to identify the root cause without relying on server-side logs alone.
What about the InMemory database?
The InMemory database resets on each app restart. This is expected for a portfolio demo. If you want persistent data, you'd swap to Azure SQL or Cosmos DB in a future project.
Enable and verify OpenAPI docs
Your code maps the OpenAPI endpoint only when the app runs in the Development environment. On Azure, the app runs in Production by default, so you need to change this setting.
- Enable the Development environment on Azure by running this command (replace the resource group name with the one from your deploy output):
az webapp config appsettings set -g [[RESOURCEGROUP="your-resource-group-name"]] -n [[APPNAME="your-unique-app-name"]] --settings ASPNETCORE_ENVIRONMENT=Development
What does this command do?
This sets an app setting on Azure that overrides the default Production environment. Your app restarts automatically with the new setting, which activates the app.MapOpenApi() line in Program.cs.
For a portfolio demo on the free tier, running in Development mode is fine. In a real production system, you'd expose OpenAPI unconditionally or use a separate docs site.
- Wait about 30 seconds for the app to restart, then open this URL in your browser:
https://[[APPNAME="your-unique-app-name"]].azurewebsites.net/openapi/v1.json
You should see a JSON document describing all your API endpoints, their request and response shapes, and validation rules. This is the OpenAPI specification for your Task API.
Seeing a 404 at the OpenAPI endpoint?
Make sure the app setting was applied. Run the following command and confirm ASPNETCORE_ENVIRONMENT appears in the list:
az webapp config appsettings list -n [[APPNAME="your-unique-app-name"]] -g [[RESOURCEGROUP="your-resource-group-name"]]
The app needs about 30 seconds to restart after a setting change. Try refreshing again after waiting.
Help me get my OpenAPI docs working on Azure
This is the URL you put on your resume. A live, documented API that hiring managers can explore without installing anything.
Your API is live on Azure with working CRUD, structured error handling, and browseable documentation. Next up, you'll tackle the Secret Mission to add filtering and pagination.
Secret mission
Add Filtering and Pagination
Real-world APIs never return all records at once. Your mission: add query parameters to the GET /api/tasks endpoint that let callers filter by completion status and paginate results with metadata like totalCount, page, and totalPages.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. This project uses the Azure App Service F1 Free tier, which costs $0 with no time limit.
Resources you used:
- Azure Resource Group (containing your App Service plan and Web App).
- Local project folder (TaskApi on your Desktop).
Keep everything running
No action needed. Choose this if you want to keep your live API URL for your resume.
- The F1 Free tier is always free with no time limit. Your API stays live at your custom URL indefinitely.
- Your local TaskApi folder remains on your Desktop for future reference.
- The InMemory database resets on each app restart, so no data accumulates.
Pause - I'll come back to this later
Free up local resources while keeping Azure ready for when you return.
- If dotnet run is still running in your terminal, press Ctrl+C to stop it.
- Your Azure App Service on the F1 tier costs nothing while idle. No need to stop or scale it down.
- Close VS Code when you are done. Your project files stay on your Desktop for next time.
Delete - I don't want to use this again
Remove all project resources and start fresh.
- Delete all Azure resources (Resource Group, App Service plan, and Web App) by running this command in your terminal:
az group delete --name [[RESOURCE_GROUP="your-resource-group-name"]] --yes --no-wait
How do I find my resource group name?
When you ran az webapp up earlier, the CLI printed the resource group it created. You can also list your groups by running:
az group list --output table
- Delete your local project folder by running:
rm -rf ~/Desktop/TaskApi
- On Windows, use this command instead:
rmdir /s /q %USERPROFILE%\Desktop\TaskApi
- Confirm the folder is gone by running:
ls ~/Desktop/TaskApi
You should see a "No such file or directory" message confirming the folder was removed.
Nice Work!
Nice Work!
You just built and deployed a production-structured REST API to a live Azure URL. This is a working portfolio piece you can link on your resume and walk through in interviews.
You've learned how to:
- Build a REST API with ASP.NET Core on .NET 10 using the modern hosting model with top-level Program.cs and no Startup.cs.
- Implement full CRUD with Entity Framework Core and meaningful HTTP status codes (200, 201, 204, 404) backed by a global exception handling middleware that returns consistent, structured error responses.
- Deploy a live API to Azure App Service using the Azure CLI with a single az webapp up command.
- Secret Mission: Added production-ready filtering and pagination with query parameters and response metadata to your GET endpoint.
Ready to quiz yourself?