Building RESTful APIs with ASP.NET Core
# 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:
[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:
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:
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`:
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:
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:
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:
Pagination, Filtering, and Sorting
Every list endpoint should support pagination from the start. Retrofitting it later is painful:
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:
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:
[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:
[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:
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:
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
Performance Tips
Security Essentials
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
What is .NET? A Modern Backend Development Guide
Learn what .NET is, how it works, and why enterprise teams choose it for backend development.
Entity Framework Core: The Modern Way to Handle Databases
Manage database operations with Entity Framework Core. Code-first approach, migrations, and performance optimization.
.NET Minimal APIs: Lightweight and Fast Endpoints
Create fast and lightweight endpoints with .NET Minimal APIs. Controller-free approach and use cases.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch