Back to blog

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

csharpdotnetaspnet-corebackendrest-api
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.net

Windows

# Option 1: winget
winget install Microsoft.DotNet.SDK.9
 
# Option 2: Download the installer from https://dot.net

Linux (Ubuntu/Debian)

sudo apt-get update
sudo apt-get install -y dotnet-sdk-9.0

Verify Installation

dotnet --version
# 9.0.x (or 8.0.x)
 
dotnet --list-sdks
# Lists all installed SDKs

Step 2: Choose Your IDE

IDEPlatformCostBest For
Visual Studio 2022Windows / macOSFree (Community)Full .NET development
VS Code + C# Dev KitAll platformsFreeLightweight development
JetBrains RiderAll platformsPaidCross-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 Kit

The 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 run

Open 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 configuration

The 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

ScenarioStatus CodeMethod
Successful GET200 OKOk(data)
Successful POST201 CreatedCreatedAtAction(...)
Successful PUT200 OKOk(data)
Successful DELETE204 No ContentNoContent()
Validation failure400 Bad RequestAutomatic
Unauthorized401 UnauthorizedUnauthorized()
Forbidden403 ForbiddenForbid()
Not found404 Not FoundNotFound()
Conflict409 ConflictConflict(data)
Server error500 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 run

Environment-Specific Files

appsettings.json               # Base (all environments)
appsettings.Development.json   # Overrides for local dev
appsettings.Production.json    # Overrides for production

The 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/1

Using 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/1

Key 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

  1. Add Search: Add a GET /api/products/search?query=laptop endpoint that filters by name.
  2. Add Pagination Headers: Return X-Total-Count, X-Page, X-Page-Size headers in the GET all endpoint.
  3. Add a Category: Add a Category field to the Product model and a GET /api/products?category=electronics filter.
  4. Add Soft Delete: Instead of removing from the list, add an IsDeleted flag and filter it out from GET responses.
  5. 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.