Skip to main content
C# intermediate Lesson 23 of 25

ASP.NET Core Basics

Minimal APIs, controllers, middleware, dependency injection, and appsettings in ASP.NET Core.

Minimal API Setup

dotnet new webapi -n MyApi --use-minimal-apis
cd MyApi
dotnet run

The generated Program.cs:

var builder = WebApplication.CreateBuilder(args);

// Register services
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

var app = builder.Build();

// Configure middleware pipeline
if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.UseHttpsRedirection();

// Define endpoints
app.MapGet("/weatherforecast", () =>
{
    var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild" };
    return Enumerable.Range(1, 5).Select(i => new
    {
        Date        = DateOnly.FromDateTime(DateTime.Now.AddDays(i)),
        TemperatureC = Random.Shared.Next(-20, 55),
        Summary     = summaries[Random.Shared.Next(summaries.Length)]
    });
})
.WithName("GetWeatherForecast")
.WithOpenApi();

app.Run();

Building a CRUD Minimal API

// Models
public record CreateProductRequest(string Name, string Category, decimal Price);
public record UpdateProductRequest(string? Name, string? Category, decimal? Price);

// Register services
builder.Services.AddSingleton<IProductRepository, InMemoryProductRepository>();

// Map routes
var products = app.MapGroup("/api/products").WithTags("Products");

products.MapGet("/", async (IProductRepository repo) =>
    Results.Ok(await repo.GetAllAsync()));

products.MapGet("/{id:int}", async (int id, IProductRepository repo) =>
{
    var product = await repo.GetByIdAsync(id);
    return product is null ? Results.NotFound() : Results.Ok(product);
});

products.MapPost("/", async (CreateProductRequest req, IProductRepository repo) =>
{
    var product = new Product { Name = req.Name, Category = req.Category, Price = req.Price };
    await repo.AddAsync(product);
    return Results.Created($"/api/products/{product.Id}", product);
});

products.MapPut("/{id:int}", async (int id, UpdateProductRequest req, IProductRepository repo) =>
{
    var existing = await repo.GetByIdAsync(id);
    if (existing is null) return Results.NotFound();

    existing.Name     = req.Name     ?? existing.Name;
    existing.Category = req.Category ?? existing.Category;
    existing.Price    = req.Price    ?? existing.Price;
    await repo.UpdateAsync(existing);
    return Results.Ok(existing);
});

products.MapDelete("/{id:int}", async (int id, IProductRepository repo) =>
{
    await repo.DeleteAsync(id);
    return Results.NoContent();
});

Controllers

For larger APIs, the controller pattern provides more structure:

using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    private readonly IProductRepository _repo;
    private readonly ILogger<ProductsController> _logger;

    public ProductsController(IProductRepository repo, ILogger<ProductsController> logger)
    {
        _repo   = repo;
        _logger = logger;
    }

    [HttpGet]
    public async Task<IActionResult> GetAll()
    {
        var products = await _repo.GetAllAsync();
        return Ok(products);
    }

    [HttpGet("{id:int}", Name = "GetProductById")]
    public async Task<IActionResult> GetById(int id)
    {
        var product = await _repo.GetByIdAsync(id);
        if (product is null)
        {
            _logger.LogWarning("Product {Id} not found", id);
            return NotFound();
        }
        return Ok(product);
    }

    [HttpPost]
    public async Task<IActionResult> Create([FromBody] CreateProductRequest req)
    {
        // Model validation happens automatically with [ApiController]
        var product = new Product { Name = req.Name, Price = req.Price };
        await _repo.AddAsync(product);
        return CreatedAtRoute("GetProductById", new { id = product.Id }, product);
    }

    [HttpPut("{id:int}")]
    public async Task<IActionResult> Update(int id, [FromBody] UpdateProductRequest req)
    {
        var existing = await _repo.GetByIdAsync(id);
        if (existing is null) return NotFound();
        // ... update logic
        return Ok(existing);
    }

    [HttpDelete("{id:int}")]
    public async Task<IActionResult> Delete(int id)
    {
        await _repo.DeleteAsync(id);
        return NoContent();
    }
}

Middleware

Middleware runs in the order it is added. Each component calls next to pass control forward.

// Built-in middleware (order matters)
app.UseExceptionHandler("/error");   // global error handling
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseRouting();
app.UseCors("AllowAll");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

// Custom middleware — inline
app.Use(async (context, next) =>
{
    var sw = Stopwatch.StartNew();
    await next(context);
    sw.Stop();
    context.Response.Headers["X-Elapsed-Ms"] = sw.ElapsedMilliseconds.ToString();
});

// Custom middleware — class
public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;

    public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        _logger.LogInformation("{Method} {Path}", context.Request.Method, context.Request.Path);
        await _next(context);
        _logger.LogInformation("Response: {StatusCode}", context.Response.StatusCode);
    }
}

// Register custom middleware
app.UseMiddleware<RequestLoggingMiddleware>();

Dependency Injection

// Register services with different lifetimes
builder.Services.AddSingleton<IConfiguration>(builder.Configuration);
builder.Services.AddSingleton<IMemoryCache, MemoryCache>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddScoped<IOrderRepository, EfOrderRepository>();
builder.Services.AddTransient<IEmailSender, SmtpEmailSender>();
builder.Services.AddHttpClient<IExternalApiClient, ExternalApiClient>(client =>
{
    client.BaseAddress = new Uri("https://api.external.com");
    client.Timeout = TimeSpan.FromSeconds(30);
});

// Inject into a service
public class OrderService : IOrderService
{
    private readonly IOrderRepository _repo;
    private readonly IEmailSender _email;
    private readonly ILogger<OrderService> _logger;

    public OrderService(
        IOrderRepository repo,
        IEmailSender email,
        ILogger<OrderService> logger)
    {
        _repo  = repo;
        _email = email;
        _logger = logger;
    }
}

Configuration with appsettings.json

{
  "ConnectionStrings": {
    "Default": "Server=localhost;Database=MyApp;Trusted_Connection=True;"
  },
  "Jwt": {
    "Secret": "super-secret-key-at-least-256-bits",
    "Issuer": "MyApp",
    "ExpiryMinutes": 60
  },
  "Features": {
    "EnableCaching": true,
    "MaxPageSize": 100
  }
}
// Strongly typed configuration
public class JwtOptions
{
    public const string SectionName = "Jwt";
    public string Secret { get; set; } = "";
    public string Issuer { get; set; } = "";
    public int ExpiryMinutes { get; set; } = 60;
}

// Register
builder.Services.Configure<JwtOptions>(
    builder.Configuration.GetSection(JwtOptions.SectionName));

// Use in a service
public class JwtService
{
    private readonly JwtOptions _options;
    public JwtService(IOptions<JwtOptions> options)
        => _options = options.Value;
}

// Read directly
string? connStr = builder.Configuration.GetConnectionString("Default");
bool caching = builder.Configuration.GetValue<bool>("Features:EnableCaching");

Environment-Specific Configuration

appsettings.json              # base configuration
appsettings.Development.json  # overrides for local dev
appsettings.Production.json   # overrides for production
// Behavior varies by environment
if (app.Environment.IsProduction())
{
    app.UseHsts();
    // Don't expose Swagger in production
}

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseDeveloperExceptionPage();
}

// Check environment in code
var env = app.Environment;
Console.WriteLine($"Running in: {env.EnvironmentName}");

Validation with Data Annotations

public class CreateProductRequest
{
    [Required]
    [StringLength(100, MinimumLength = 2)]
    public string Name { get; set; } = "";

    [Required]
    public string Category { get; set; } = "";

    [Range(0.01, 100_000)]
    public decimal Price { get; set; }

    [Range(0, int.MaxValue)]
    public int Stock { get; set; }
}

// [ApiController] validates automatically and returns 400 with details
// For minimal APIs, add validation manually:
app.MapPost("/api/products", async (
    [FromBody] CreateProductRequest req,
    IValidator<CreateProductRequest> validator) =>
{
    var validation = await validator.ValidateAsync(req);
    if (!validation.IsValid)
        return Results.ValidationProblem(validation.ToDictionary());

    // ... create product
    return Results.Created("/api/products/1", req);
});

Frequently Asked Questions

Should I use minimal APIs or controllers?
Minimal APIs are great for microservices and small APIs — less ceremony, faster startup, easier to read. Controllers are better for large applications with many endpoints, complex routing, and teams that want consistent structure with action filters and model binding conventions.
What is middleware in ASP.NET Core?
Middleware is a pipeline of components that process HTTP requests and responses. Each component can run code before and after the next component in the pipeline. Common uses: logging, authentication, exception handling, CORS, compression, routing.
What is the difference between AddScoped, AddSingleton, and AddTransient?
AddTransient creates a new instance every time it is requested. AddScoped creates one instance per HTTP request. AddSingleton creates one instance for the lifetime of the application. Use Scoped for database contexts, Transient for lightweight stateless services, Singleton for expensive shared resources.