Beginner

Getting Started with ASP.NET Core 10 Web API

Build a REST API with the CityInfo project — resources, validation, updates, DI and configuration.

Course based on the CityInfo.API project — a REST API that exposes cities and their points of interest.


Table of Contents

  1. Introduction and Core Concepts
  2. Creating the API and Returning Resources
  3. Customizing API Responses
  4. Manipulating Resources and Input Validation
  5. Full and Partial Updates
  6. Dependency Injection and Configuration
  7. Quick Reference

1. Introduction and Core Concepts

What Is a REST API?

An API (Application Programming Interface) is a contract between a service provider (the server) and a consumer (the client). A REST API (Representational State Transfer) is an API that adheres to REST architectural constraints, making it a very popular architectural style for web services.

Key REST characteristics:

  • Stateless: each request contains all the information needed; the server does not maintain session state on the server side.
  • Resources identified by URIs: e.g. /api/cities, /api/cities/1.
  • Representations: resources are transferred as representations (JSON, XML…).
  • HTTP methods: GET, POST, PUT, PATCH, DELETE.

ASP.NET Core 10 Web API

ASP.NET Core is a cross-platform, open-source, high-performance web framework developed by Microsoft. It implements the MVC (Model-View-Controller) pattern, but for an API, the View layer is removed and the focus is on Controllers that return data.

graph TD
    A[HTTP Client / Browser / App] -->|HTTP Request| B[ASP.NET Core Web API]
    B -->|HTTP Response JSON/XML| A
    B --> C[Controller]
    C --> D[Service / Business Logic]
    D --> E[Data Store / Database]
    E --> D
    D --> C
    C --> B

Overall Architecture of an ASP.NET Core Web API Project

graph LR
    subgraph CityInfo.API
        P[Program.cs\nEntry Point]
        P --> MW[Middleware Pipeline]
        MW --> CR[Controller Routing]
        CR --> CC[CitiesController]
        CR --> PC[PointsOfInterestController]
        CR --> FC[FilesController]
        CC --> DS[CitiesDataStore]
        PC --> DS
        PC --> MS[IMailService]
        MS --> LS[LocalMailService]
        MS --> CS[CloudMailService]
    end
    subgraph Models
        CDto[CityDto]
        PDto[PointOfInterestDto]
        PCreate[PointOfInterestForCreationDto]
        PUpdate[PointOfInterestForUpdateDto]
    end
    CC --> CDto
    PC --> PDto
    PC --> PCreate
    PC --> PUpdate

Project Structure

CityInfo.API/
├── Program.cs                          ← Entry point, DI and middleware configuration
├── CityInfo.API.csproj                 ← .NET project file
├── appsettings.json                    ← Configuration (dev + prod)
├── appsettings.Development.json        ← Development-specific configuration
├── CitiesDataStore.cs                  ← In-memory data store
├── Controllers/
│   ├── CitiesController.cs             ← GET /api/cities endpoint
│   ├── PointsOfInterestController.cs   ← CRUD /api/cities/{id}/pointsofinterest
│   └── FilesController.cs              ← File upload/download
├── Models/
│   ├── CityDto.cs
│   ├── PointOfInterestDto.cs
│   ├── PointOfInterestForCreationDto.cs
│   └── PointOfInterestForUpdateDto.cs
└── Services/
    ├── IMailService.cs
    ├── LocalMailService.cs
    └── CloudMailService.cs

2. Creating the API and Returning Resources

The Middleware Pipeline

In ASP.NET Core, every HTTP request travels through a middleware pipeline configured in Program.cs. Each middleware can process the request, pass it to the next one, or short-circuit the pipeline.

sequenceDiagram
    participant Client
    participant HTTPS as UseHttpsRedirection
    participant Auth as UseAuthorization
    participant Router as MapControllers
    participant Controller

    Client->>HTTPS: HTTP Request
    HTTPS->>Auth: Redirects to HTTPS if needed
    Auth->>Router: Checks authorizations
    Router->>Controller: Routes to the correct controller
    Controller-->>Router: ActionResult (200, 404, etc.)
    Router-->>Auth: Response
    Auth-->>HTTPS: Response
    HTTPS-->>Client: HTTP Response

Program.cs — Application Entry Point

using CityInfo.API;
using CityInfo.API.Services;
using Microsoft.AspNetCore.StaticFiles;

var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();
builder.Logging.AddConsole();

// Register services in the DI container
builder.Services.AddControllers()
    .AddXmlDataContractSerializerFormatters(); // XML support in addition to JSON

builder.Services.AddOpenApi(); // OpenAPI documentation (Swagger)

builder.Services.AddProblemDetails(options =>
    options.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions
            .Add("additionalInfo", "Additional info example");
        ctx.ProblemDetails.Extensions
            .Add("server", Environment.MachineName);
    });

builder.Services.AddSingleton<FileExtensionContentTypeProvider>();

#if DEBUG
builder.Services.AddTransient<IMailService, LocalMailService>();
#else
builder.Services.AddTransient<IMailService, CloudMailService>();
#endif

builder.Services.AddSingleton<CitiesDataStore>();

var app = builder.Build();

// Configure the HTTP pipeline (middleware)
if (app.Environment.IsDevelopment())
{
    app.MapOpenApi();
}

app.UseHttpsRedirection();
app.UseAuthorization();
app.MapControllers(); // Enables routing to controllers

app.Run();

Key insight: builder configures services (dependency injection), and app configures the pipeline (middlewares). This is the fundamental separation in ASP.NET Core.

HTTP Request Flow

sequenceDiagram
    participant Client
    participant API as ASP.NET Core API
    participant Controller
    participant DataStore

    Client->>API: GET /api/cities
    API->>Controller: Route → CitiesController.GetCities()
    Controller->>DataStore: citiesDataStore.Cities
    DataStore-->>Controller: List<CityDto>
    Controller-->>API: Ok(cities) → 200 OK
    API-->>Client: JSON Response

Models (DTOs — Data Transfer Objects)

A DTO is an object whose sole purpose is to transport data between the client and the API. It contains no business logic.

Important distinction: What the API returns (DTO) is NOT the same as what is stored in the database (Entity). This separation is essential.

// CityDto.cs — what the API returns to the client
namespace CityInfo.API.Models;

public class CityDto
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string? Description { get; set; }

    // Calculated property: no need to store it, it is derived
    public int NumberOfPointsOfInterest => PointsOfInterest.Count;

    // Initialized to an empty list to avoid NullReferenceException
    public ICollection<PointOfInterestDto> PointsOfInterest { get; set; } = [];
}
// PointOfInterestDto.cs — read representation of a point of interest
namespace CityInfo.API.Models;

public class PointOfInterestDto
{
    public int Id { get; set; }
    public string Name { get; set; } = string.Empty;
    public string? Description { get; set; }
}

The CitiesDataStore — In-Memory Data

For this course, data is stored in memory (no database). This is sufficient to learn API concepts. In production, Entity Framework Core with a real database would be used.

// CitiesDataStore.cs
using CityInfo.API.Models;

namespace CityInfo.API;

public class CitiesDataStore
{
    public List<CityDto> Cities { get; set; }

    public CitiesDataStore()
    {
        Cities =
        [
            new()
            {
                Id = 1,
                Name = "New York City",
                Description = "The one with that big park.",
                PointsOfInterest = [
                    new() {
                        Id = 1,
                        Name = "Central Park",
                        Description = "The most visited urban park in the United States." },
                    new() {
                        Id = 2,
                        Name = "Empire State Building",
                        Description = "A 102-story skyscraper located in Midtown Manhattan." }
                ]
            },
            new()
            {
                Id = 2,
                Name = "Antwerp",
                Description = "The one with the cathedral that was never really finished.",
                PointsOfInterest = [
                    new() {
                        Id = 3,
                        Name = "Cathedral of Our Lady",
                        Description = "A Gothic style cathedral, conceived by architects Jan and Pieter Appelmans." },
                    new() {
                        Id = 4,
                        Name = "Antwerp Central Station",
                        Description = "The finest example of railway architecture in Belgium." }
                ]
            },
            new()
            {
                Id = 3,
                Name = "Paris",
                Description = "The one with that big tower.",
                PointsOfInterest = [
                    new() {
                        Id = 5,
                        Name = "Eiffel Tower",
                        Description = "A wrought iron lattice tower on the Champ de Mars, named after engineer Gustave Eiffel." },
                    new() {
                        Id = 6,
                        Name = "The Louvre",
                        Description = "The world's largest museum." }
                ]
            }
        ];
    }
}

The CitiesController

// Controllers/CitiesController.cs
using CityInfo.API.Models;
using Microsoft.AspNetCore.Mvc;

namespace CityInfo.API.Controllers;

[ApiController]           // Enables API-friendly behaviors (auto validation, etc.)
[Route("api/cities")]     // Route prefix for all actions in this controller
public class CitiesController(CitiesDataStore citiesDataStore) : ControllerBase
{
    // GET /api/cities
    [HttpGet()]
    public ActionResult<IEnumerable<CityDto>> GetCities()
    {
        return Ok(citiesDataStore.Cities);
    }

    // GET /api/cities/{id}
    [HttpGet("{id}")]
    public ActionResult<CityDto> GetCity(int id)
    {
        var cityToReturn = citiesDataStore
            .Cities.FirstOrDefault(c => c.Id == id);

        if (cityToReturn == null)
        {
            return NotFound(); // 404 Not Found
        }

        return Ok(cityToReturn); // 200 OK
    }
}

Routing Attributes

AttributeHTTP MethodUsage
[HttpGet]GETRead one or more resources
[HttpPost]POSTCreate a new resource
[HttpPut]PUTFull update of a resource
[HttpPatch]PATCHPartial update of a resource
[HttpDelete]DELETEDelete a resource
[Route("template")]Define a route prefix at the controller level

Tip: The [Route] attribute at the controller level defines the common prefix. You can use [controller] as a placeholder that automatically takes the controller name without the “Controller” suffix. However, it is better to write the name explicitly so URIs remain stable even if the class is renamed.

ActionResult and IActionResult

ActionResult<T> is the recommended return type. It allows returning either a typed result (e.g. CityDto) or an HTTP result with a status code.

Helper methodHTTP CodeWhen to use
Ok(value)200Successful request with content
Created(uri, value)201Resource created successfully
CreatedAtRoute(name, routeValues, value)201Creation with Location header link
NoContent()204Success with no content (DELETE, PUT)
BadRequest()400Invalid client-side request
NotFound()404Resource not found
Unauthorized()401Not authenticated
Forbid()403Not authorized
StatusCode(500)500Generic server error

3. Customizing API Responses

HTTP Status Codes — Why They Matter

Status codes are the only way for the API consumer to know whether their request succeeded. Here are the main levels:

graph LR
    A[HTTP Status Codes] --> B[1xx Informational]
    A --> C[2xx Success ✅]
    A --> D[3xx Redirection]
    A --> E[4xx Client Error ⚠️]
    A --> F[5xx Server Error ❌]
    C --> C1[200 OK]
    C --> C2[201 Created]
    C --> C3[204 No Content]
    E --> E1[400 Bad Request]
    E --> E2[401 Unauthorized]
    E --> E3[403 Forbidden]
    E --> E4[404 Not Found]
    E --> E5[409 Conflict]
    E --> E6[422 Unprocessable Entity]
    F --> F1[500 Internal Server Error]

Golden rule: An error caused by the client (bad request, resource not found) → 4xx codes. An error caused by the server (bug, outage) → 5xx codes. Never return 200 OK for an error!

Problem Details — RFC Standard

When an error occurs, ASP.NET Core automatically returns a response that follows the RFC 7807 Problem Details for HTTP APIs standard. This standard defines a common JSON format for errors.

Example Problem Details response for a 404:

{
  "type": "https://tools.ietf.org/html/rfc7807",
  "title": "Not Found",
  "status": 404,
  "traceId": "00-abc123...",
  "additionalInfo": "Additional info example",
  "server": "SAMUEL"
}

Customization is done in Program.cs:

builder.Services.AddProblemDetails(options =>
    options.CustomizeProblemDetails = ctx =>
    {
        ctx.ProblemDetails.Extensions
            .Add("additionalInfo", "Additional info example");
        ctx.ProblemDetails.Extensions
            .Add("server", Environment.MachineName); // Useful in multi-server environments
    });

Content Negotiation

Content negotiation is the process by which the client and server agree on the response format. The client sends an Accept header with the desired media type.

sequenceDiagram
    participant Client
    participant API

    Client->>API: GET /api/cities\nAccept: application/json
    API-->>Client: 200 OK\nContent-Type: application/json\n[JSON data]

    Client->>API: GET /api/cities\nAccept: application/xml
    API-->>Client: 200 OK\nContent-Type: application/xml\n[XML data]

    Client->>API: GET /api/cities\nAccept: application/csv
    API-->>Client: 406 Not Acceptable\n(format not supported)

Enabling XML support:

builder.Services.AddControllers()
    .AddXmlDataContractSerializerFormatters(); // Adds XML for both input and output
TypeDescriptionExample
Output FormatterSerializes the response (server → client)JSON, XML
Input FormatterDeserializes the request body (client → server)JSON, XML
Accept headerClient requests a response formatAccept: application/xml
Content-Type headerClient indicates the format of the sent bodyContent-Type: application/json

Returning a File

// Controllers/FilesController.cs
[Route("api/files")]
[ApiController]
public class FilesController(FileExtensionContentTypeProvider fileExtensionContentTypeProvider)
    : ControllerBase
{
    [HttpGet("{fileId}")]
    public async Task<ActionResult> GetFile(string fileId)
    {
        var pathToFile = "sample-file.pdf";

        if (!System.IO.File.Exists(pathToFile))
        {
            return NotFound();
        }

        // Automatically determine the Content-Type based on the extension
        if (!fileExtensionContentTypeProvider.TryGetContentType(pathToFile,
            out var contentType))
        {
            contentType = "application/octet-stream"; // Generic binary type
        }

        // async/await for I/O operations (file read = I/O)
        var bytes = await System.IO.File.ReadAllBytesAsync(pathToFile);

        return File(bytes, contentType, Path.GetFileName(pathToFile));
    }
}

async / await — Asynchronous Programming

graph TD
    A[Incoming HTTP request] --> B[Thread pool allocates a thread]
    B --> C{I/O operation?}
    C -->|No - CPU computation| D[Thread blocked until done]
    C -->|Yes - file, DB, network| E[await releases the thread]
    E --> F[Thread returns to pool]
    F --> G[Other requests use this thread]
    G --> H[I/O done: thread recovered from pool]
    H --> I[Response sent to client]

Fundamental rules:

  • async is a modifier on the method — it allows await to be used inside.
  • await is an operator — it waits for an asynchronous task to complete without blocking the thread.
  • An async method must return void, Task, or Task<T>.
  • Use async/await only for I/O operations (files, network, database). For purely in-memory computations, it is unnecessary and consumes more memory.
// Synchronous (blocks the thread)
var bytes = System.IO.File.ReadAllBytes(pathToFile);

// Asynchronous (releases the thread during the read)
var bytes = await System.IO.File.ReadAllBytesAsync(pathToFile);

4. Manipulating Resources and Input Validation

Binding Source Attributes — Where Do Parameters Come From?

ASP.NET Core needs to know where to find the values for an action’s parameters. The [ApiController] attribute configures smart default rules.

AttributeData sourceExample
[FromRoute]URL segment/api/cities/{id}id
[FromQuery]Query string/api/cities?page=1page
[FromBody]Request body (JSON)JSON body → DTO object
[FromHeader]HTTP headerAccept: application/json
[FromForm]HTML form / filesIFormFile
[FromServices]DI containerInjected service

Behavior with [ApiController]:

  • Complex types (classes) → [FromBody] inferred automatically
  • IFormFile / IFormFileCollection[FromForm] inferred automatically
  • Simple parameters (int, string) matching a route template → [FromRoute] inferred
  • Other simple parameters → [FromQuery] inferred

Separate DTOs for Each Operation

Golden rule: Use a distinct DTO for each type of operation (create, update, read). Even if the fields are identical.

graph LR
    A[Operation] --> B[DTO used]
    B1[Read GET] --> C1[PointOfInterestDto\nId + Name + Description]
    B2[Create POST] --> C2[PointOfInterestForCreationDto\nName + Description\nno Id - server assigns it]
    B3[Update PUT/PATCH] --> C3[PointOfInterestForUpdateDto\nName + Description\nwith validations]

Why?

  1. The client does not choose the resource ID — the server assigns it.
  2. Validations for creation may differ from those for updates.
  3. Makes refactoring easier without breaking the API.

Data Annotations — Declarative Validation

// Models/PointOfInterestForCreationDto.cs
using System.ComponentModel.DataAnnotations;

namespace CityInfo.API.Models;

public class PointOfInterestForCreationDto
{
    [Required(ErrorMessage = "You must provide a name value.")]
    [MaxLength(50)]
    public string Name { get; set; } = string.Empty;

    [MaxLength(200)]
    public string? Description { get; set; }
}
AttributeDescription
[Required]Field is mandatory
[MaxLength(n)]Maximum string length
[MinLength(n)]Minimum string length
[Range(min, max)]Numeric value between min and max
[EmailAddress]Validates email format
[Url]Validates URL format
[RegularExpression(pattern)]Validates against a regex
[StringLength(max)]Maximum length with message

With the [ApiController] attribute, ModelState validation is automatic: if annotations fail, the API automatically returns a 400 Bad Request with validation error details. No need to manually check ModelState.IsValid.

Creating a Resource — POST

// Excerpt from PointsOfInterestController.cs
[HttpPost]
public ActionResult<PointOfInterestDto> CreatePointOfInterest(
    int cityId,
    PointOfInterestForCreationDto pointOfInterest) // [FromBody] inferred automatically
{
    var city = citiesDataStore.Cities.FirstOrDefault(c => c.Id == cityId);
    if (city is null)
    {
        return NotFound();
    }

    // Calculate the next ID (in-memory, no DB)
    var maxPointOfInterestId = citiesDataStore.Cities
        .SelectMany(c => c.PointsOfInterest).Max(p => p.Id);

    var finalPointOfInterest = new PointOfInterestDto()
    {
        Id = ++maxPointOfInterestId,
        Name = pointOfInterest.Name,
        Description = pointOfInterest.Description
    };

    city.PointsOfInterest.Add(finalPointOfInterest);

    // 201 Created + Location header pointing to the new resource
    return CreatedAtRoute("GetPointOfInterest",
        new { cityId, pointOfInterestId = finalPointOfInterest.Id },
        finalPointOfInterest);
}

REST standard: A successful creation returns 201 Created with a Location header pointing to the URI of the new resource. Example: Location: /api/cities/1/pointsofinterest/7

Deleting a Resource — DELETE

[HttpDelete("{pointOfInterestId}")]
public ActionResult DeletePointOfInterest(int cityId, int pointOfInterestId)
{
    var city = citiesDataStore.Cities
        .FirstOrDefault(c => c.Id == cityId);
    if (city is null) return NotFound();

    var pointOfInterestFromStore = city.PointsOfInterest
        .FirstOrDefault(c => c.Id == pointOfInterestId);
    if (pointOfInterestFromStore is null) return NotFound();

    city.PointsOfInterest.Remove(pointOfInterestFromStore);

    mailService.Send("Point of interest deleted.",
        $"Point of interest {pointOfInterestFromStore.Name} with id {pointOfInterestFromStore.Id} was deleted.");

    return NoContent(); // 204 No Content — success with no response body
}

Uploading a File — POST with IFormFile

[HttpPost]
public async Task<ActionResult> CreateFile(IFormFile file)
{
    // Essential security validation!
    // 1. Check size (prevent large-file attacks - max 20MB here)
    // 2. Check Content-Type (prevent malicious files)
    if (file.Length is 0
        || file.Length > 20971520          // 20 MB
        || file.ContentType != "application/pdf")
    {
        return BadRequest("No file or an invalid one has been inputted.");
    }

    // ⚠️ SECURITY: Never store files in the API directory
    // Use a separate directory without execute permissions
    var path = Path.Combine(
        Directory.GetCurrentDirectory(),
        $"uploaded_file_{Guid.NewGuid()}.pdf");

    using (var stream = new FileStream(path, FileMode.Create))
    {
        await file.CopyToAsync(stream);
    }

    return Ok("Your file has been uploaded successfully!");
}

⚠️ File security: File names and Content-Types can be forged by attackers. Always store files in a secure directory, without execute permissions, and preferably outside the web application tree.


5. Full and Partial Updates

PUT — Full Update

The PUT method performs a full update: the client must send all fields of the resource, even those it does not want to modify. If a field is omitted, it will be set to null or its default value.

// Models/PointOfInterestForUpdateDto.cs
using System.ComponentModel.DataAnnotations;

public class PointOfInterestForUpdateDto
{
    [Required(ErrorMessage = "You should provide a name value.")]
    [MaxLength(50)]
    public string Name { get; set; } = string.Empty;

    [MaxLength(200)]
    public string? Description { get; set; }
}
[HttpPut("{pointOfInterestId}")]
public ActionResult UpdatePointOfInterest(
    int cityId,
    int pointOfInterestId,
    PointOfInterestForUpdateDto pointOfInterest)
{
    var city = citiesDataStore.Cities.FirstOrDefault(c => c.Id == cityId);
    if (city is null) return NotFound();

    var pointOfInterestFromStore = city.PointsOfInterest
        .FirstOrDefault(c => c.Id == pointOfInterestId);
    if (pointOfInterestFromStore is null) return NotFound();

    // Full update: all fields are overwritten
    pointOfInterestFromStore.Name = pointOfInterest.Name;
    pointOfInterestFromStore.Description = pointOfInterest.Description;

    return NoContent(); // 204 — success with no body
}

PATCH — Partial Update with JSON Patch

The PATCH method allows modifying only the specified fields. It uses the JSON Patch (RFC 6902) standard.

JSON Patch Document Format

[
  {
    "op": "replace",
    "path": "/name",
    "value": "New Name"
  },
  {
    "op": "replace",
    "path": "/description",
    "value": "New description"
  }
]
Operation (op)Description
addAdds a value or element to an array
removeRemoves a value
replaceReplaces an existing value
copyCopies a value from one path to another
moveMoves a value from one path to another
testVerifies a value matches before applying

Required NuGet package:

Microsoft.AspNetCore.JsonPatch.SystemTextJson
[HttpPatch("{pointOfInterestId}")]
public ActionResult PartiallyUpdatePointOfInterest(
    int cityId,
    int pointOfInterestId,
    JsonPatchDocument<PointOfInterestForUpdateDto> patchDocument)
{
    var city = citiesDataStore.Cities.FirstOrDefault(c => c.Id == cityId);
    if (city is null) return NotFound();

    var pointOfInterestFromStore = city.PointsOfInterest
        .FirstOrDefault(c => c.Id == pointOfInterestId);
    if (pointOfInterestFromStore is null) return NotFound();

    // Convert to update DTO (not PointOfInterestDto
    // to prevent modification of the ID)
    var pointOfInterestToPatch = new PointOfInterestForUpdateDto()
    {
        Name = pointOfInterestFromStore.Name,
        Description = pointOfInterestFromStore.Description
    };

    // Apply the patch document — errors are added to ModelState
    patchDocument.ApplyTo(pointOfInterestToPatch, jsonPatchError =>
    {
        var key = jsonPatchError.AffectedObject.GetType().Name;
        ModelState.AddModelError(key, jsonPatchError.ErrorMessage);
    });

    // Validate ModelState after patch application
    if (!ModelState.IsValid) return BadRequest(ModelState);

    // Validate Data Annotations on the resulting DTO
    if (!TryValidateModel(pointOfInterestToPatch)) return BadRequest(ModelState);

    // Apply changes to the resource
    pointOfInterestFromStore.Name = pointOfInterestToPatch.Name;
    pointOfInterestFromStore.Description = pointOfInterestToPatch.Description;

    return NoContent(); // 204
}

PUT vs PATCH Comparison

graph LR
    subgraph PUT Full Update
        A1[Client sends ALL fields\neven unmodified ones]
        A2[Resource fully replaced]
        A1 --> A2
    end
    subgraph PATCH Partial Update
        B1[Client sends only\nchanges via JSON Patch]
        B2[Only specified fields\nare modified]
        B1 --> B2
    end

6. Dependency Injection and Configuration

Dependency Injection (DI) — Principle

Dependency injection is a fundamental design pattern in ASP.NET Core. Rather than creating dependencies directly (new MyService()), a class receives them as parameters (typically through the constructor). The framework creates and manages the instances.

Advantages:

  • Loose coupling between classes
  • Ease of unit testing (mocks can be injected)
  • Centralized lifetime management of objects
graph LR
    subgraph Without DI
        C1[PointsOfInterestController] -->|new LocalMailService| M1[LocalMailService]
    end
    subgraph With DI
        C2[PointsOfInterestController] -->|IMailService injected| I[IMailService interface]
        I --> M2[LocalMailService in dev]
        I --> M3[CloudMailService in prod]
    end

Mail Service Interface and Implementations

// Services/IMailService.cs
namespace CityInfo.API.Services;

public interface IMailService
{
    void Send(string subject, string message);
}
// Services/LocalMailService.cs
namespace CityInfo.API.Services;

public class LocalMailService(IConfiguration configuration) : IMailService
{
    // Reading configuration via IConfiguration
    private string _mailTo = configuration["mailSettings:mailToAddress"]
        ?? throw new ArgumentNullException("mailSettings:mailToAddress");
    private string _mailFrom = configuration["mailSettings:mailFromAddress"]
        ?? throw new ArgumentNullException("mailSettings:mailFromAddress");

    public void Send(string subject, string message)
    {
        // In development: output to console
        Console.WriteLine($"Mail from {_mailFrom} to {_mailTo}, with {nameof(LocalMailService)}.");
        Console.WriteLine($"Subject: {subject}");
        Console.WriteLine($"Message: {message}");
    }
}

Service Lifetimes in the DI Container

MethodLifetimeWhen to use
AddSingleton<T>()Single instance for the entire app lifetimeShared resources, caches, in-memory stores
AddScoped<T>()One instance per HTTP requestEntity Framework DbContext, request-scoped services
AddTransient<T>()New instance on every injectionLightweight, stateless services
// In Program.cs — service registration
builder.Services.AddSingleton<FileExtensionContentTypeProvider>();

// Based on build configuration (DEBUG or Release)
#if DEBUG
builder.Services.AddTransient<IMailService, LocalMailService>();
#else
builder.Services.AddTransient<IMailService, CloudMailService>();
#endif

// Data store is Singleton: one in-memory instance for the entire app
builder.Services.AddSingleton<CitiesDataStore>();

Injection in a Controller via Primary Constructor

In C# 12 and .NET 8+, the Primary Constructor syntax simplifies dependency injection:

// Old C# syntax (still valid)
public class PointsOfInterestController : ControllerBase
{
    private readonly ILogger<PointsOfInterestController> _logger;
    private readonly IMailService _mailService;
    private readonly CitiesDataStore _citiesDataStore;

    public PointsOfInterestController(
        ILogger<PointsOfInterestController> logger,
        IMailService mailService,
        CitiesDataStore citiesDataStore)
    {
        _logger = logger;
        _mailService = mailService;
        _citiesDataStore = citiesDataStore;
    }
}

// New C# 12 syntax — Primary Constructor (used in this course)
public class PointsOfInterestController(
    ILogger<PointsOfInterestController> logger,
    IMailService mailService,
    CitiesDataStore citiesDataStore) : ControllerBase
{
    // Parameters are directly available in all methods
}

Logging with ILogger

ASP.NET Core provides a built-in logging system via ILogger<T>. Logs have several severity levels:

LevelMethodUsage
TraceLogTrace()Very detailed diagnostics
DebugLogDebug()Debug information
InformationLogInformation()Normal events
WarningLogWarning()Abnormal but non-critical situation
ErrorLogError()Error requiring attention
CriticalLogCritical()Severe failure
// Using the logger in the controller
[HttpGet]
public ActionResult<IEnumerable<PointOfInterestDto>> GetPointsOfInterest(int cityId)
{
    var city = citiesDataStore.Cities.FirstOrDefault(c => c.Id == cityId);
    if (city is null)
    {
        // Structured message with named parameter {CityId} — avoids string interpolation
        logger.LogInformation(
            "City with id {CityId} wasn't found when accessing points of interest.", cityId);
        return NotFound();
    }
    return Ok(city.PointsOfInterest);
}

Configuration — appsettings.json

// appsettings.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "mailSettings": {
    "mailToAddress": "admin@mycompany.com",
    "mailFromAddress": "noreply@mycompany.com"
  }
}

Configuration is accessible via IConfiguration injected into services:

// In a service
public class LocalMailService(IConfiguration configuration) : IMailService
{
    private string _mailTo = configuration["mailSettings:mailToAddress"] ?? "default@mail.com";
    // The key uses ":" as a JSON hierarchy separator
}

7. Quick Reference

REST Conventions

OperationHTTP MethodURIBodySuccess Status Code
List all citiesGET/api/cities200 OK
Get a cityGET/api/cities/{id}200 OK
List POIs for a cityGET/api/cities/{id}/pointsofinterest200 OK
Get a POIGET/api/cities/{id}/pointsofinterest/{poiId}200 OK
Create a POIPOST/api/cities/{id}/pointsofinterestJSON201 Created
Full updatePUT/api/cities/{id}/pointsofinterest/{poiId}JSON204 No Content
Partial updatePATCH/api/cities/{id}/pointsofinterest/{poiId}JSON Patch204 No Content
Delete a POIDELETE/api/cities/{id}/pointsofinterest/{poiId}204 No Content
Download a fileGET/api/files/{fileId}200 OK
Upload a filePOST/api/filesmultipart/form-data200 OK

Essential ASP.NET Core Attributes

AttributeLevelDescription
[ApiController]ControllerEnables API behaviors (auto validation, inferred binding)
[Route("template")]Controller / ActionDefines the route
[HttpGet] / [HttpPost] / etc.ActionMaps an HTTP method
[Required]DTO propertyMandatory field
[MaxLength(n)]DTO propertyMax length
[FromBody]ParameterRequest body
[FromRoute]ParameterURL segment
[FromQuery]ParameterQuery string

NuGet Packages Used in This Course

PackageUsage
Microsoft.AspNetCore.OpenApiOpenAPI / Swagger documentation
Microsoft.AspNetCore.JsonPatch.SystemTextJsonJSON Patch support (PATCH requests)

Summary: Full POST Request Flow

sequenceDiagram
    participant Client
    participant Middleware
    participant Controller
    participant DataStore

    Client->>Middleware: POST /api/cities/1/pointsofinterest\nBody: {"name":"Times Square","description":"..."}
    Middleware->>Middleware: HTTPS Redirect
    Middleware->>Middleware: Authorization
    Middleware->>Controller: Route → PointsOfInterestController.CreatePointOfInterest(cityId=1, dto)
    Note over Controller: [ApiController] automatically validates\nData Annotations on the DTO
    Controller->>DataStore: Cities.FirstOrDefault(c => c.Id == 1)
    DataStore-->>Controller: CityDto found
    Controller->>DataStore: city.PointsOfInterest.Add(newPOI)
    Controller-->>Middleware: CreatedAtRoute(201) + Location header
    Middleware-->>Client: HTTP 201 Created\nLocation: /api/cities/1/pointsofinterest/7\nBody: {id:7, name:"Times Square",...}

Search Terms

asp.net · core · web · api · apis · c# · .net · development · attributes · data · injection · patch · post · configuration · dependency · dtos · flow · http · json · partial · put · request · resource · resources

Interested in this course?

Contact us to book it or get a custom training plan for your team.