Articles › ASP.NET Core

Building a REST API with ASP.NET Core from Scratch

A step-by-step guide to building a production-ready ASP.NET Core Web API: project setup, controllers, DTOs, EF Core, validation, status codes and Swagger.

ASP.NET Core is one of the fastest and most complete frameworks for building web APIs. In this tutorial we'll build a small but realistic Products API from an empty folder to a clean, documented, production-ready service — and explain why each piece exists along the way.

Prerequisites

  • The .NET 8 SDK or newer
  • An editor: Visual Studio, Rider or VS Code
  • Basic C# knowledge (classes, async/await)

1. Create the project

dotnet new webapi -n Shop.Api --use-controllers
cd Shop.Api
dotnet run

The template creates a Program.cs, a sample controller and Swagger configuration. Open https://localhost:5001/swagger and you'll see interactive documentation for every endpoint.

Tip: --use-controllers generates controller-based code. Without it, newer templates use Minimal APIs. Both are fine — controllers scale better for larger APIs with many endpoints and filters.

2. Understand Program.cs

Program.cs does two jobs: register services in the dependency-injection container and configure the HTTP pipeline.

var builder = WebApplication.CreateBuilder(args);

// 1) Services
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// 2) Middleware pipeline (order matters!)
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers();

app.Run();

Everything before builder.Build() is configuration; everything after it describes how each request flows through the application.

3. Model the domain

Create an Entities folder with a Product class:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string? Description { get; set; }
    public decimal Price { get; set; }
    public int Stock { get; set; }
    public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}

4. Add Entity Framework Core

dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Design
public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options)
{
    public DbSet<Product> Products => Set<Product>();

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.Entity<Product>(e =>
        {
            e.Property(p => p.Name).HasMaxLength(150).IsRequired();
            e.Property(p => p.Price).HasPrecision(18, 2);
        });
    }
}

Register it in Program.cs and add the connection string to appsettings.json:

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));
{
  "ConnectionStrings": {
    "Default": "Server=.;Database=ShopDb;Trusted_Connection=True;TrustServerCertificate=True"
  }
}

Then create the database:

dotnet tool install --global dotnet-ef
dotnet ef migrations add InitialCreate
dotnet ef database update

5. Never expose entities directly: use DTOs

Returning entities from your API couples your database schema to your public contract. A new column suddenly appears in every response, and clients can even over-post fields they shouldn't control (like CreatedAt). DTOs (Data Transfer Objects) solve this:

public record ProductDto(int Id, string Name, string? Description, decimal Price, bool InStock);

public record CreateProductRequest(
    [property: Required, MaxLength(150)] string Name,
    string? Description,
    [property: Range(0.01, 1_000_000)] decimal Price,
    [property: Range(0, int.MaxValue)] int Stock);

C# record types are perfect here: concise, immutable and compared by value.

6. Write the controller

[ApiController]
[Route("api/products")]
public class ProductsController(AppDbContext db) : ControllerBase
{
    [HttpGet]
    public async Task<ActionResult<List<ProductDto>>> GetAll(
        [FromQuery] string? search, CancellationToken ct)
    {
        var query = db.Products.AsNoTracking();
        if (!string.IsNullOrWhiteSpace(search))
            query = query.Where(p => p.Name.Contains(search));

        return await query
            .OrderBy(p => p.Name)
            .Select(p => new ProductDto(p.Id, p.Name, p.Description, p.Price, p.Stock > 0))
            .ToListAsync(ct);
    }

    [HttpGet("{id:int}")]
    public async Task<ActionResult<ProductDto>> GetById(int id, CancellationToken ct)
    {
        var product = await db.Products.AsNoTracking()
            .Where(p => p.Id == id)
            .Select(p => new ProductDto(p.Id, p.Name, p.Description, p.Price, p.Stock > 0))
            .FirstOrDefaultAsync(ct);

        return product is null ? NotFound() : product;
    }

    [HttpPost]
    public async Task<ActionResult<ProductDto>> Create(CreateProductRequest request, CancellationToken ct)
    {
        var product = new Product
        {
            Name = request.Name,
            Description = request.Description,
            Price = request.Price,
            Stock = request.Stock
        };

        db.Products.Add(product);
        await db.SaveChangesAsync(ct);

        var dto = new ProductDto(product.Id, product.Name, product.Description, product.Price, product.Stock > 0);
        return CreatedAtAction(nameof(GetById), new { id = product.Id }, dto);
    }

    [HttpPut("{id:int}")]
    public async Task<IActionResult> Update(int id, CreateProductRequest request, CancellationToken ct)
    {
        var product = await db.Products.FindAsync([id], ct);
        if (product is null) return NotFound();

        product.Name = request.Name;
        product.Description = request.Description;
        product.Price = request.Price;
        product.Stock = request.Stock;

        await db.SaveChangesAsync(ct);
        return NoContent();
    }

    [HttpDelete("{id:int}")]
    public async Task<IActionResult> Delete(int id, CancellationToken ct)
    {
        var deleted = await db.Products.Where(p => p.Id == id).ExecuteDeleteAsync(ct);
        return deleted == 0 ? NotFound() : NoContent();
    }
}

A few details worth noticing:

  • [ApiController] enables automatic model validation: if a request violates the data annotations, ASP.NET Core returns 400 Bad Request with a detailed ProblemDetails body — no if (!ModelState.IsValid) needed.
  • AsNoTracking() makes read-only queries faster because EF Core doesn't track the entities.
  • Projecting with Select sends only the needed columns to SQL Server.
  • {id:int} is a route constraint: /api/products/abc never reaches this action.
  • ExecuteDeleteAsync deletes in a single SQL statement without loading the entity first.

7. Return the right status codes

Operation Success Common errors
GET list 200 OK —
GET by id 200 OK 404 Not Found
POST 201 Created + Location header 400 Bad Request
PUT 204 No Content (or 200 with body) 400, 404
DELETE 204 No Content 404

Correct status codes make your API predictable for frontend developers and for tools like Swagger and HTTP clients.

8. Add pagination

Never return an unbounded list. A simple, effective pattern:

public record PagedResult<T>(IReadOnlyList<T> Items, int Page, int PageSize, int TotalCount);

[HttpGet("paged")]
public async Task<PagedResult<ProductDto>> GetPaged(int page = 1, int pageSize = 20, CancellationToken ct = default)
{
    pageSize = Math.Clamp(pageSize, 1, 100);
    var query = db.Products.AsNoTracking().OrderBy(p => p.Id);

    var total = await query.CountAsync(ct);
    var items = await query
        .Skip((page - 1) * pageSize)
        .Take(pageSize)
        .Select(p => new ProductDto(p.Id, p.Name, p.Description, p.Price, p.Stock > 0))
        .ToListAsync(ct);

    return new PagedResult<ProductDto>(items, page, pageSize, total);
}

9. Move logic out of controllers

As the API grows, controllers should stay thin: receive the request, call a service, return a result. Business logic belongs in a service layer:

public interface IProductService
{
    Task<ProductDto?> GetAsync(int id, CancellationToken ct);
    Task<ProductDto> CreateAsync(CreateProductRequest request, CancellationToken ct);
}

builder.Services.AddScoped<IProductService, ProductService>();

This makes logic reusable and unit-testable, and is the first step towards a layered or Clean Architecture design.

10. Production checklist

  • Global error handling with UseExceptionHandler and ProblemDetails
  • Authentication (usually JWT) and [Authorize] on protected endpoints
  • CORS configured for your frontend's origin
  • Logging with structured logs (Serilog or the built-in logger)
  • Health checks with builder.Services.AddHealthChecks() and app.MapHealthChecks("/health")
  • Rate limiting for public endpoints
  • Swagger enabled only where appropriate

Summary

We built a complete CRUD API with ASP.NET Core: a clean Program.cs, EF Core with migrations, DTOs instead of entities, validation via [ApiController], correct HTTP status codes, pagination and a path towards a service layer. These fundamentals stay the same whether your API has five endpoints or five hundred.

FAQ

Controllers or Minimal APIs?

Both are production-ready. Minimal APIs are great for small services and microservices; controllers give more structure (filters, conventions, grouping) for large APIs.

Should I use AutoMapper for DTOs?

For small projects, manual mapping or Select projections are clearer and faster. Mapping libraries help when you have many similar DTOs — just avoid hiding important logic in mapping profiles.

How do I version my API?

Use URL versioning (/api/v1/products) or the Asp.Versioning.Mvc package. Plan for versioning before you have external clients.