Building RESTful APIs with ASP.NET Core

15 min readFebruary 9, 2026Updated: Mar 9, 2026
ASP.NET Core APIREST API .NETWeb API tutorialASP.NET Core controllerAPI development C#.NET REST best practicesSwagger .NETAPI versioning

# RESTful APIs with ASP.NET Core

ASP.NET Core is one of the strongest options for building production-grade REST APIs. It gives you a fast HTTP pipeline, mature middleware, and strong conventions for maintainable services. In production APIs I've built, the framework has consistently proven itself under heavy load, but getting the most out of it requires deliberate architectural choices from day one.

API Foundation

Project Setup and Controller Structure

A well-organized API starts with a clean controller layer. Controllers should be thin orchestrators that delegate to services:

csharp
[ApiController]
[Route(class="code-string">"api/v{version:apiVersion}/[controller]")]
[Produces(class="code-string">"application/json")]
public class ProductsController : ControllerBase
{
    private readonly IProductService _productService;
    private readonly ILogger<ProductsController> _logger;

    public ProductsController(IProductService productService, ILogger<ProductsController> logger)
    {
        _productService = productService;
        _logger = logger;
    }

    [HttpGet]
    [ProducesResponseType(typeof(PagedResult<ProductDto>), StatusCodes.Status200OK)]
    public async Task<IActionResult> GetAll([FromQuery] ProductQueryParameters query)
    {
        var result = await _productService.GetAllAsync(query);
        return Ok(result);
    }

    [HttpGet(class="code-string">"{id:guid}")]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status200OK)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
    public async Task<IActionResult> GetById(Guid id)
    {
        var product = await _productService.GetByIdAsync(id);
        if (product is null)
            return NotFound();

        return Ok(product);
    }

    [HttpPost]
    [ProducesResponseType(typeof(ProductDto), StatusCodes.Status201Created)]
    [ProducesResponseType(typeof(ValidationProblemDetails), StatusCodes.Status400BadRequest)]
    public async Task<IActionResult> Create([FromBody] CreateProductRequest request)
    {
        var product = await _productService.CreateAsync(request);
        return CreatedAtAction(nameof(GetById), new { id = product.Id }, product);
    }

    [HttpPut(class="code-string">"{id:guid}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status404NotFound)]
    public async Task<IActionResult> Update(Guid id, [FromBody] UpdateProductRequest request)
    {
        await _productService.UpdateAsync(id, request);
        return NoContent();
    }

    [HttpDelete(class="code-string">"{id:guid}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    public async Task<IActionResult> Delete(Guid id)
    {
        await _productService.DeleteAsync(id);
        return NoContent();
    }
}

Model Binding and DTOs

Never expose your domain entities directly. Use dedicated request and response models to keep your API contract stable:

csharp
public record CreateProductRequest(
    string Name,
    string Description,
    decimal Price,
    string Category
);

public record ProductDto(
    Guid Id,
    string Name,
    string Description,
    decimal Price,
    string Category,
    DateTime CreatedAt
);

public record ProductQueryParameters
{
    public int Page { get; init; } = class="code-number">1;
    public int PageSize { get; init; } = class="code-number">20;
    public string? SortBy { get; init; }
    public string? Search { get; init; }
}

Validation with FluentValidation

Data annotations are fine for simple cases, but FluentValidation shines when rules get complex. In production APIs I've built, FluentValidation has saved countless hours debugging malformed requests:

csharp
public class CreateProductRequestValidator : AbstractValidator<CreateProductRequest>
{
    public CreateProductRequestValidator()
    {
        RuleFor(x => x.Name)
            .NotEmpty().WithMessage(class="code-string">"Product name is required.")
            .MaximumLength(class="code-number">200).WithMessage(class="code-string">"Product name cannot exceed class="code-number">200 characters.");

        RuleFor(x => x.Description)
            .MaximumLength(class="code-number">2000);

        RuleFor(x => x.Price)
            .GreaterThan(class="code-number">0).WithMessage(class="code-string">"Price must be a positive value.")
            .PrecisionScale(class="code-number">10, class="code-number">2, ignoreTrailingZeros: true)
            .WithMessage(class="code-string">"Price cannot have more than class="code-number">2 decimal places.");

        RuleFor(x => x.Category)
            .NotEmpty()
            .Must(BeAValidCategory).WithMessage(class="code-string">"Invalid category.");
    }

    private bool BeAValidCategory(string category)
    {
        var validCategories = new[] { class="code-string">"Electronics", class="code-string">"Clothing", class="code-string">"Books", class="code-string">"Food" };
        return validCategories.Contains(category);
    }
}

Register FluentValidation in your `Program.cs`:

csharp
builder.Services.AddValidatorsFromAssemblyContaining<CreateProductRequestValidator>();
builder.Services.AddFluentValidationAutoValidation();

Global Error Handling with ProblemDetails

Consistent error responses are non-negotiable. Use the built-in `ProblemDetails` infrastructure combined with a global exception handler:

csharp
builder.Services.AddProblemDetails(options =>
{
    options.CustomizeProblemDetails = context =>
    {
        context.ProblemDetails.Extensions[class="code-string">"traceId"] = context.HttpContext.TraceIdentifier;
    };
});

app.UseExceptionHandler(appError =>
{
    appError.Run(async context =>
    {
        context.Response.ContentType = class="code-string">"application/problem+json";
        var exceptionFeature = context.Features.Get<IExceptionHandlerFeature>();
        var exception = exceptionFeature?.Error;

        var problemDetails = exception switch
        {
            NotFoundException ex => new ProblemDetails
            {
                Status = StatusCodes.Status404NotFound,
                Title = class="code-string">"Resource Not Found",
                Detail = ex.Message,
                Type = class="code-string">"https:class="code-commentclass="code-string">">//tools.ietf.org/html/rfc9110#section-class="code-number">15.5.class="code-number">5"
            },
            ConflictException ex => new ProblemDetails
            {
                Status = StatusCodes.Status409Conflict,
                Title = class="code-string">"Conflict",
                Detail = ex.Message,
                Type = class="code-string">"https:class="code-commentclass="code-string">">//tools.ietf.org/html/rfc9110#section-class="code-number">15.5.class="code-number">10"
            },
            _ => new ProblemDetails
            {
                Status = StatusCodes.Status500InternalServerError,
                Title = class="code-string">"Internal Server Error",
                Detail = class="code-string">"An unexpected error occurred.",
                Type = class="code-string">"https:class="code-commentclass="code-string">">//tools.ietf.org/html/rfc9110#section-class="code-number">15.6.class="code-number">1"
            }
        };

        context.Response.StatusCode = problemDetails.Status ?? class="code-number">500;
        await context.Response.WriteAsJsonAsync(problemDetails);
    });
});

API Design Best Practices

Resource Naming Conventions

Use plural nouns for resources and keep URLs predictable:

  • `GET /api/v1/products` — list all products
  • `GET /api/v1/products/{id}` — get a single product
  • `POST /api/v1/products` — create a product
  • `PUT /api/v1/products/{id}` — full update
  • `PATCH /api/v1/products/{id}` — partial update
  • `DELETE /api/v1/products/{id}` — delete a product
  • `GET /api/v1/products/{id}/reviews` — nested sub-resources
  • Avoid verbs in URLs. Instead of `/api/products/getAll`, just use `GET /api/products`. The HTTP method already communicates the action.

    HTTP Verbs and Status Codes

    Be deliberate about status codes. Returning 200 for everything is a missed opportunity to communicate intent:

    | Operation | Success Code | Meaning |

    |-----------|-------------|---------|

    | GET | 200 OK | Resource found and returned |

    | POST | 201 Created | Resource created, include Location header |

    | PUT | 204 No Content | Resource replaced successfully |

    | PATCH | 200 OK | Resource partially updated, return updated state |

    | DELETE | 204 No Content | Resource deleted |

    For errors, stick to these:

  • **400** — Bad request, validation failures
  • **401** — Not authenticated
  • **403** — Authenticated but not authorized
  • **404** — Resource not found
  • **409** — Conflict (duplicate, version mismatch)
  • **422** — Semantically invalid request
  • **429** — Rate limited
  • **500** — Server error (never intentional)
  • Pagination, Filtering, and Sorting

    Every list endpoint should support pagination from the start. Retrofitting it later is painful:

    csharp
    public class PagedResult<T>
    {
        public IReadOnlyList<T> Items { get; init; } = [];
        public int TotalCount { get; init; }
        public int Page { get; init; }
        public int PageSize { get; init; }
        public bool HasNextPage => Page * PageSize < TotalCount;
        public bool HasPreviousPage => Page > class="code-number">1;
    }

    Versioning Strategy

    Plan for versioning from the first release. In production APIs I've built, skipping versioning has always led to painful breaking-change migrations later. URL-based versioning is the most explicit and client-friendly approach:

    csharp
    builder.Services.AddApiVersioning(options =>
    {
        options.DefaultApiVersion = new ApiVersion(class="code-number">1, class="code-number">0);
        options.AssumeDefaultVersionWhenUnspecified = true;
        options.ReportApiVersions = true;
        options.ApiVersionReader = ApiVersionReader.Combine(
            new UrlSegmentApiVersionReader(),
            new HeaderApiVersionReader(class="code-string">"X-API-Version")
        );
    })
    .AddApiExplorer(options =>
    {
        options.GroupNameFormat = class="code-string">"class="code-string">'v'VVV";
        options.SubstituteApiVersionInUrl = true;
    });

    When you need a breaking change, create a new controller version while keeping the old one alive:

    csharp
    [ApiController]
    [Route(class="code-string">"api/v{version:apiVersion}/products")]
    [ApiVersion(class="code-string">"class="code-number">2.0")]
    public class ProductsV2Controller : ControllerBase
    {
        [HttpGet(class="code-string">"{id:guid}")]
        public async Task<IActionResult> GetById(Guid id)
        {
            class=class="code-string">"code-comment">// V2 returns a richer response shape
            var product = await _productService.GetByIdDetailedAsync(id);
            return Ok(product);
        }
    }

    Mark deprecated versions explicitly so consumers have time to migrate:

    csharp
    [ApiVersion(class="code-string">"class="code-number">1.0", Deprecated = true)]

    OpenAPI/Swagger Documentation

    Good API documentation eliminates most support questions. Set up Swagger with rich metadata:

    csharp
    builder.Services.AddEndpointsApiExplorer();
    builder.Services.AddSwaggerGen(options =>
    {
        options.SwaggerDoc(class="code-string">"v1", new OpenApiInfo
        {
            Title = class="code-string">"Product Catalog API",
            Version = class="code-string">"v1",
            Description = class="code-string">"API for managing the product catalog.",
            Contact = new OpenApiContact
            {
                Name = class="code-string">"API Support",
                Email = class="code-string">"api-support@example.com"
            }
        });
    
        options.SwaggerDoc(class="code-string">"v2", new OpenApiInfo
        {
            Title = class="code-string">"Product Catalog API",
            Version = class="code-string">"v2"
        });
    
        class=class="code-string">"code-comment">// Include XML comments from your project
        var xmlFile = $class="code-string">"{Assembly.GetExecutingAssembly().GetName().Name}.xml";
        var xmlPath = Path.Combine(AppContext.BaseDirectory, xmlFile);
        options.IncludeXmlComments(xmlPath);
    
        class=class="code-string">"code-comment">// Add JWT authentication to Swagger UI
        options.AddSecurityDefinition(class="code-string">"Bearer", new OpenApiSecurityScheme
        {
            Description = class="code-string">"JWT Authorization header using the Bearer scheme.",
            Name = class="code-string">"Authorization",
            In = ParameterLocation.Header,
            Type = SecuritySchemeType.Http,
            Scheme = class="code-string">"bearer"
        });
    
        options.AddSecurityRequirement(new OpenApiSecurityRequirement
        {
            {
                new OpenApiSecurityScheme
                {
                    Reference = new OpenApiReference
                    {
                        Type = ReferenceType.SecurityScheme,
                        Id = class="code-string">"Bearer"
                    }
                },
                Array.Empty<string>()
            }
        });
    });

    Enable the Swagger middleware:

    csharp
    if (app.Environment.IsDevelopment())
    {
        app.UseSwagger();
        app.UseSwaggerUI(options =>
        {
            options.SwaggerEndpoint(class="code-string">"/swagger/v1/swagger.json", class="code-string">"Product API V1");
            options.SwaggerEndpoint(class="code-string">"/swagger/v2/swagger.json", class="code-string">"Product API V2");
        });
    }

    Production Best Practices

  • **Idempotency keys** for critical write endpoints — use an `Idempotency-Key` header to safely retry failed POST requests
  • **Correlation IDs** for distributed tracing — propagate a `X-Correlation-Id` header across service boundaries
  • **Health checks** for orchestrators — expose `/health/live` and `/health/ready` endpoints
  • **Rate limiting** to protect your service — use the built-in `RateLimiter` middleware in .NET 7+
  • **Output caching** for expensive read-heavy endpoints
  • Performance Tips

  • Prefer `async`/`await` end-to-end — a single synchronous call in the chain blocks a thread pool thread
  • Use `IAsyncEnumerable<T>` for streaming large datasets instead of buffering everything in memory
  • Apply response compression with `UseResponseCompression()` for JSON-heavy payloads
  • Profile with `dotnet-trace` and `dotnet-counters` before micro-optimizing
  • Use `System.Text.Json` source generators for AOT-friendly serialization
  • Security Essentials

  • Enforce HTTPS everywhere with `UseHttpsRedirection()` and HSTS headers
  • Use JWT/OAuth2 for stateless authentication with short-lived access tokens
  • Apply policy-based authorization for fine-grained access control
  • Never log sensitive data — mask PII and secrets in structured logs
  • Validate and sanitize all user input, even in authenticated endpoints
  • Common API Design Mistakes

    Returning 200 for everything. When your API returns `200 OK` with `{ "success": false, "error": "Not found" }`, you're fighting HTTP rather than using it. Clients cannot rely on status codes, middleware like caches and load balancers get confused, and retry logic breaks.

    Exposing domain entities as responses. The moment your database schema leaks into your API contract, every schema migration becomes a breaking change. Always use dedicated DTOs.

    Ignoring pagination on list endpoints. It works fine with 50 records in development. Then production hits 500,000 records and your endpoint starts timing out. Always paginate from day one.

    Not versioning from the start. "We'll add versioning when we need it" means you'll be doing an emergency migration when a breaking change is unavoidable. URL-based versioning costs nothing to set up.

    Inconsistent error formats. If some endpoints return `{ "error": "message" }` and others return `ProblemDetails`, every client has to handle multiple formats. Pick one standard and enforce it globally.

    Skipping request validation. Trusting that clients will send well-formed data is a recipe for corrupted state and cryptic 500 errors. Validate at the boundary, always.

    Conclusion

    ASP.NET Core provides the structure and performance needed for reliable enterprise APIs, but quality comes from clear contracts, strong observability, and disciplined release practices. In production APIs I've built, the difference between a good and great API always comes down to the details: consistent error handling, thoughtful versioning, and documentation that developers actually want to read.

    I can help design and harden your API architecture for production.

    Related Articles

    Have a Flutter Project?

    I build high-performance Flutter applications for iOS, Android, and web.

    Get in Touch