Phase 1: Getting Started with ASP.NET Core & Web API

Series: ASP.NET Core Learning Roadmap
Time commitment: 5–7 days, 1–2 hours daily
Prerequisites: C# fundamentals, basic OOP, REST API concepts
Introduction
In this phase you'll build your first real REST API with ASP.NET Core — a CRUD API for managing a list of products. By the end, you'll understand how the framework fits together and be ready to connect a real database in Phase 2.
What you'll build: A Products API with full CRUD (Create, Read, Update, Delete), input validation, Swagger documentation, and proper HTTP response handling.
What You'll Learn
✅ Install .NET SDK and set up your development environment
✅ Understand the ASP.NET Core project structure
✅ Create controllers, actions, and routes
✅ Bind request bodies, query strings, and route parameters
✅ Validate input with Data Annotations and return errors
✅ Return correct HTTP status codes for every scenario
✅ Document your API with Swagger / OpenAPI
✅ Manage configuration with appsettings.json and environment variables
Step 1: Install .NET SDK
ASP.NET Core requires the .NET SDK (version 8 or 9).
macOS
# Option 1: Homebrew (recommended)
brew install dotnet
# Option 2: Download the installer from https://dot.netWindows
# Option 1: winget
winget install Microsoft.DotNet.SDK.9
# Option 2: Download the installer from https://dot.netLinux (Ubuntu/Debian)
sudo apt-get update
sudo apt-get install -y dotnet-sdk-9.0Verify Installation
dotnet --version
# 9.0.x (or 8.0.x)
dotnet --list-sdks
# Lists all installed SDKsStep 2: Choose Your IDE
| IDE | Platform | Cost | Best For |
|---|---|---|---|
| Visual Studio 2022 | Windows / macOS | Free (Community) | Full .NET development |
| VS Code + C# Dev Kit | All platforms | Free | Lightweight development |
| JetBrains Rider | All platforms | Paid | Cross-platform power users |
Recommendation for beginners: Visual Studio 2022 on Windows (most integrated), or VS Code with the C# Dev Kit extension on macOS/Linux.
VS Code Setup
If using VS Code, install these extensions:
C# Dev KitThe C# Dev Kit includes IntelliSense, debugging, test explorer, and solution explorer.
Step 3: Create Your First Project
# Create a new Web API project
dotnet new webapi -n ProductsApi --use-controllers
cd ProductsApi
# Run the project
dotnet runOpen your browser at http://localhost:5000/swagger — you'll see the Swagger UI with a sample WeatherForecast endpoint. This is your starting point.
Understanding the Project Structure
ProductsApi/
├── Controllers/ # API controllers (request handling)
│ └── WeatherForecastController.cs
├── Properties/
│ └── launchSettings.json # Dev environment URLs and profiles
├── appsettings.json # Application configuration
├── appsettings.Development.json # Dev-only configuration overrides
├── ProductsApi.csproj # Project file (dependencies, SDK version)
└── Program.cs # Application entry point and configurationThe Program.cs File
This is where your application starts. Open it — it contains two sections:
// 1. SERVICE REGISTRATION — configure what the app can do
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers(); // Enable MVC controllers
builder.Services.AddEndpointsApiExplorer(); // Enable endpoint discovery
builder.Services.AddSwaggerGen(); // Enable Swagger UI
// 2. MIDDLEWARE PIPELINE — configure how requests flow
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers(); // Wire up controller routes
app.Run();Think of it in two phases:
- Builder phase: register services (DI container)
- Pipeline phase: configure middleware (request handling order)
Step 4: Your First Controller
Delete WeatherForecastController.cs and WeatherForecast.cs. Create a new controller:
// Controllers/ProductsController.cs
using Microsoft.AspNetCore.Mvc;
namespace ProductsApi.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
// In-memory storage for now (Phase 2 will add a real database)
private static readonly List<Product> _products = new()
{
new Product { Id = 1, Name = "Laptop", Price = 999.99m, Stock = 10 },
new Product { Id = 2, Name = "Mouse", Price = 29.99m, Stock = 50 },
new Product { Id = 3, Name = "Keyboard", Price = 79.99m, Stock = 30 },
};
private static int _nextId = 4;
// GET api/products
[HttpGet]
public ActionResult<IEnumerable<Product>> GetAll()
{
return Ok(_products);
}
// GET api/products/1
[HttpGet("{id}")]
public ActionResult<Product> GetById(int id)
{
var product = _products.FirstOrDefault(p => p.Id == id);
if (product is null)
return NotFound();
return Ok(product);
}
// POST api/products
[HttpPost]
public ActionResult<Product> Create([FromBody] CreateProductRequest request)
{
var product = new Product
{
Id = _nextId++,
Name = request.Name,
Price = request.Price,
Stock = request.Stock
};
_products.Add(product);
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
}
// PUT api/products/1
[HttpPut("{id}")]
public ActionResult<Product> Update(int id, [FromBody] UpdateProductRequest request)
{
var product = _products.FirstOrDefault(p => p.Id == id);
if (product is null)
return NotFound();
product.Name = request.Name;
product.Price = request.Price;
product.Stock = request.Stock;
return Ok(product);
}
// DELETE api/products/1
[HttpDelete("{id}")]
public IActionResult Delete(int id)
{
var product = _products.FirstOrDefault(p => p.Id == id);
if (product is null)
return NotFound();
_products.Remove(product);
return NoContent();
}
}The Model Classes
Create a Models folder with the following classes:
// Models/Product.cs
namespace ProductsApi.Models;
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public int Stock { get; set; }
}// Models/CreateProductRequest.cs
namespace ProductsApi.Models;
public class CreateProductRequest
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public int Stock { get; set; }
}// Models/UpdateProductRequest.cs
namespace ProductsApi.Models;
public class UpdateProductRequest
{
public string Name { get; set; } = string.Empty;
public decimal Price { get; set; }
public int Stock { get; set; }
}Note: You'll need to add
using ProductsApi.Models;at the top of the controller, or configure a global namespace in your project file.
Run the project again (dotnet run) and test your endpoints in Swagger.
Step 5: Understanding Routing
ASP.NET Core has two routing mechanisms:
Attribute Routing (used in Web APIs)
[Route("api/[controller]")] // [controller] resolves to "products"
public class ProductsController : ControllerBase
{
[HttpGet] // GET /api/products
[HttpGet("{id}")] // GET /api/products/1
[HttpPost] // POST /api/products
[HttpPut("{id}")] // PUT /api/products/1
[HttpDelete("{id}")] // DELETE /api/products/1
}Route Parameters
// Route parameter from URL: /api/products/42
[HttpGet("{id}")]
public ActionResult<Product> GetById(int id) { ... }
// Query string: /api/products?page=2&pageSize=10
[HttpGet]
public ActionResult<IEnumerable<Product>> GetAll(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 10) { ... }
// Request body: POST /api/products with JSON body
[HttpPost]
public ActionResult<Product> Create([FromBody] CreateProductRequest request) { ... }
// Route + body: PUT /api/products/42 with JSON body
[HttpPut("{id}")]
public ActionResult<Product> Update(int id, [FromBody] UpdateProductRequest request) { ... }The binding source attributes ([FromRoute], [FromQuery], [FromBody], [FromHeader]) tell ASP.NET Core where to get each parameter. When you use [ApiController], most bindings are inferred automatically.
Step 6: Input Validation
Data Annotations
Add validation directly to your request models:
using System.ComponentModel.DataAnnotations;
public class CreateProductRequest
{
[Required]
[StringLength(100, MinimumLength = 2)]
public string Name { get; set; } = string.Empty;
[Required]
[Range(0.01, 999999.99)]
public decimal Price { get; set; }
[Required]
[Range(0, int.MaxValue)]
public int Stock { get; set; }
}With [ApiController] on your controller, ASP.NET Core automatically validates incoming requests and returns a 400 Bad Request with error details if validation fails. You don't need to check ModelState.IsValid manually.
Custom Validation
For more complex rules, implement IValidatableObject:
public class CreateProductRequest : IValidatableObject
{
[Required]
[StringLength(100, MinimumLength = 2)]
public string Name { get; set; } = string.Empty;
[Range(0.01, 999999.99)]
public decimal Price { get; set; }
[Range(0, int.MaxValue)]
public int Stock { get; set; }
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Price > 1000 && Stock > 100)
{
yield return new ValidationResult(
"High-price items cannot have more than 100 units in stock.",
new[] { nameof(Price), nameof(Stock) });
}
}
}Validation Error Response Format
When validation fails, ASP.NET Core returns this by default:
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.5.1",
"title": "One or more validation errors occurred.",
"status": 400,
"errors": {
"Name": ["The Name field is required."],
"Price": ["The field Price must be between 0.01 and 999999.99."]
}
}Step 7: HTTP Status Codes — The Right Response for Every Situation
Returning the right status code is essential for a well-designed API:
// 200 OK — successful GET, PUT
return Ok(product);
// 201 Created — successful POST (with Location header)
return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
// 204 No Content — successful DELETE (no body)
return NoContent();
// 400 Bad Request — invalid input (automatic with [ApiController])
return BadRequest(new { error = "Custom error message" });
// 404 Not Found — resource doesn't exist
return NotFound();
// 409 Conflict — resource already exists
return Conflict(new { error = "A product with this name already exists." });Status Code Reference
| Scenario | Status Code | Method |
|---|---|---|
| Successful GET | 200 OK | Ok(data) |
| Successful POST | 201 Created | CreatedAtAction(...) |
| Successful PUT | 200 OK | Ok(data) |
| Successful DELETE | 204 No Content | NoContent() |
| Validation failure | 400 Bad Request | Automatic |
| Unauthorized | 401 Unauthorized | Unauthorized() |
| Forbidden | 403 Forbidden | Forbid() |
| Not found | 404 Not Found | NotFound() |
| Conflict | 409 Conflict | Conflict(data) |
| Server error | 500 Internal Server Error | (exception middleware) |
Step 8: Swagger / OpenAPI Documentation
Swagger UI is included by default in new projects. It auto-generates interactive documentation from your code.
Enhancing Swagger with XML Comments
Add XML documentation to your actions:
/// <summary>
/// Get all products with optional filtering.
/// </summary>
/// <param name="page">Page number (default: 1)</param>
/// <param name="pageSize">Items per page (default: 10)</param>
/// <returns>A list of products</returns>
/// <response code="200">Returns the list of products</response>
[HttpGet]
[ProducesResponseType(typeof(IEnumerable<Product>), StatusCodes.Status200OK)]
public ActionResult<IEnumerable<Product>> GetAll(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 10)
{
var paged = _products.Skip((page - 1) * pageSize).Take(pageSize);
return Ok(paged);
}Enable XML comments in your .csproj:
<PropertyGroup>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);1591</NoWarn>
</PropertyGroup>And configure Swagger to use them in Program.cs:
builder.Services.AddSwaggerGen(options =>
{
options.SwaggerDoc("v1", new OpenApiInfo
{
Title = "Products API",
Version = "v1",
Description = "A sample API for managing products"
});
var xmlFile = $"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
options.IncludeXmlComments(xmlPath);
});Add using System.Reflection; and using Microsoft.OpenApi.Models; at the top.
Step 9: Configuration Management
appsettings.json
Use for application settings that vary by environment:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Api": {
"MaxPageSize": 100,
"DefaultPageSize": 10
}
}Reading Configuration in Code
// Option 1: Direct (simple values)
var maxPageSize = builder.Configuration.GetValue<int>("Api:MaxPageSize");
// Option 2: Options pattern (recommended for grouped settings)
builder.Services.Configure<ApiSettings>(
builder.Configuration.GetSection("Api"));// Models/ApiSettings.cs
public class ApiSettings
{
public int MaxPageSize { get; set; } = 100;
public int DefaultPageSize { get; set; } = 10;
}Inject into your controller:
public class ProductsController : ControllerBase
{
private readonly ApiSettings _settings;
public ProductsController(IOptions<ApiSettings> settings)
{
_settings = settings.Value;
}
[HttpGet]
public ActionResult<IEnumerable<Product>> GetAll(
[FromQuery] int page = 1,
[FromQuery] int? pageSize = null)
{
var size = Math.Min(pageSize ?? _settings.DefaultPageSize, _settings.MaxPageSize);
var paged = _products.Skip((page - 1) * size).Take(size);
return Ok(paged);
}
}Environment Variables
Override configuration with environment variables:
# Overrides "Api:MaxPageSize" in appsettings.json
export Api__MaxPageSize=50 # Use double underscore for nested keys
dotnet runEnvironment-Specific Files
appsettings.json # Base (all environments)
appsettings.Development.json # Overrides for local dev
appsettings.Production.json # Overrides for productionThe environment is controlled by the ASPNETCORE_ENVIRONMENT variable (Development, Staging, Production).
Step 10: Global Exception Handling
Don't let unhandled exceptions leak stack traces to clients. Add global exception handling in Program.cs:
// Program.cs — add before app.MapControllers()
app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
{
context.Response.StatusCode = 500;
context.Response.ContentType = "application/json";
var error = context.Features.Get<IExceptionHandlerFeature>();
if (error != null)
{
await context.Response.WriteAsJsonAsync(new
{
type = "https://tools.ietf.org/html/rfc9110#section-15.6.1",
title = "An unexpected error occurred.",
status = 500
});
}
});
});Or in .NET 8+, use the cleaner IExceptionHandler interface:
// Infrastructure/GlobalExceptionHandler.cs
using Microsoft.AspNetCore.Diagnostics;
public class GlobalExceptionHandler : IExceptionHandler
{
private readonly ILogger<GlobalExceptionHandler> _logger;
public GlobalExceptionHandler(ILogger<GlobalExceptionHandler> logger)
{
_logger = logger;
}
public async ValueTask<bool> TryHandleAsync(
HttpContext httpContext,
Exception exception,
CancellationToken cancellationToken)
{
_logger.LogError(exception, "Unhandled exception");
httpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
await httpContext.Response.WriteAsJsonAsync(new
{
title = "An unexpected error occurred.",
status = 500
}, cancellationToken);
return true;
}
}Register it in Program.cs:
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
builder.Services.AddProblemDetails();
// In the pipeline:
app.UseExceptionHandler();Putting It All Together
Here's the complete flow for a POST request:
Testing Your API
Using the Swagger UI
The Swagger UI at /swagger lets you test all endpoints directly in the browser. Click any endpoint, hit "Try it out", fill in the values, and execute.
Using curl
# GET all products
curl http://localhost:5000/api/products
# GET single product
curl http://localhost:5000/api/products/1
# POST create product
curl -X POST http://localhost:5000/api/products \
-H "Content-Type: application/json" \
-d '{"name": "Monitor", "price": 399.99, "stock": 5}'
# PUT update product
curl -X PUT http://localhost:5000/api/products/1 \
-H "Content-Type: application/json" \
-d '{"name": "Laptop Pro", "price": 1299.99, "stock": 8}'
# DELETE product
curl -X DELETE http://localhost:5000/api/products/1Using VS Code REST Client
Install the "REST Client" extension and create a requests.http file:
### Get all products
GET http://localhost:5000/api/products
### Get product by ID
GET http://localhost:5000/api/products/1
### Create product
POST http://localhost:5000/api/products
Content-Type: application/json
{
"name": "Monitor",
"price": 399.99,
"stock": 5
}
### Update product
PUT http://localhost:5000/api/products/1
Content-Type: application/json
{
"name": "Laptop Pro",
"price": 1299.99,
"stock": 8
}
### Delete product
DELETE http://localhost:5000/api/products/1Key Concepts to Remember
[ApiController] — adds automatic validation, binding inference, and problem details responses.
ControllerBase — base class for Web API controllers. Has all the helper methods (Ok, NotFound, CreatedAtAction, etc.) but no View support.
ActionResult<T> — return type that supports both the typed result and any IActionResult (like NotFound()).
Practice Exercises
- Add Search: Add a
GET /api/products/search?query=laptopendpoint that filters by name. - Add Pagination Headers: Return
X-Total-Count,X-Page,X-Page-Sizeheaders in the GET all endpoint. - Add a Category: Add a
Categoryfield to theProductmodel and aGET /api/products?category=electronicsfilter. - Add Soft Delete: Instead of removing from the list, add an
IsDeletedflag and filter it out from GET responses. - Custom Error Response: Customize the 404 response to return
{ "error": "Product with ID {id} not found." }.
Summary
You've built a complete REST API with ASP.NET Core from scratch. Here's what you covered:
✅ .NET SDK installation and project creation
✅ Project structure: Program.cs, controllers, models
✅ Routing with attribute routing and HTTP method attributes
✅ Model binding: route params, query strings, request bodies
✅ Input validation with Data Annotations
✅ Correct HTTP status codes for every scenario
✅ Swagger / OpenAPI documentation
✅ Configuration with appsettings.json and environment variables
✅ Global exception handling
In Phase 2, you'll replace the in-memory list with a real PostgreSQL database using Entity Framework Core.
Series: ASP.NET Core Learning Roadmap
Previous: ASP.NET Core Learning Roadmap (Overview)
Next: Phase 2: Data Access with Entity Framework Core →
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.