Building a RESTful API with ASP.NET Core Web API
A well-designed API gives web applications, mobile clients and third-party services a dependable way to exchange data. ASP.NET Core Web API provides the plumbing for this work: routing, dependency injection, middleware, model binding, validation, authentication and a high-performance HTTP server. The framework is suitable for a small internal service as well as a public platform serving customers across Australia.
This walkthrough builds a practical API for managing projects and tasks. The examples use modern C#, Entity Framework Core and SQL Server, while keeping the design principles applicable to PostgreSQL or another relational database. Along the way, the focus is on resource-oriented routes, useful status codes, predictable errors and an API that remains maintainable as the codebase grows.
Set Up The ASP.NET Core Project
Create a Web API project with the .NET CLI:
dotnet new webapi -n TaskApi
cd TaskApi
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.EntityFrameworkCore.Tools
dotnet add package Swashbuckle.AspNetCore
Recent ASP.NET Core templates use minimal hosting by default, with application configuration in Program.cs. That style is concise, although controllers remain a strong choice for larger APIs because they group related endpoints and make conventions clear.
A basic Program.cs can register controllers, Entity Framework Core and Swagger:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();
builder.Services.AddDbContext<TaskDbContext>(options =>
options.UseSqlServer(
builder.Configuration.GetConnectionString("TaskDatabase")));
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseHttpsRedirection();
app.MapControllers();
app.Run();
Keep connection strings outside source control. During local development, user secrets are preferable to committing passwords in appsettings.json. In production, use a managed secret store or environment variables. An Australian organisation might deploy to Azure Australia East or Australia Southeast to keep latency and data residency requirements in mind, but the same configuration pattern works in any hosting environment.
Model Resources And Routes Clearly
A REST API models nouns as resources. In this example, a project contains tasks, so the routes could be:
GET /api/projects
GET /api/projects/{id}
POST /api/projects
PUT /api/projects/{id}
DELETE /api/projects/{id}
GET /api/projects/{projectId}/tasks
POST /api/projects/{projectId}/tasks
These routes communicate intent without embedding actions such as /createProject or /completeTask. HTTP methods carry the operation. GET retrieves data, POST creates a resource, PUT replaces a resource, PATCH applies a partial update and DELETE removes one.
Create entity classes that represent persisted data:
public class Project
{
public int Id { get; set; }
public required string Name { get; set; }
public string? Description { get; set; }
public DateTime CreatedUtc { get; set; }
public ICollection<TaskItem> Tasks { get; set; } = [];
}
public class TaskItem
{
public int Id { get; set; }
public required string Title { get; set; }
public bool IsComplete { get; set; }
public int ProjectId { get; set; }
public Project? Project { get; set; }
}
Avoid returning entity types directly from every controller action. Database entities often contain navigation properties, internal fields or relationships that should not be exposed publicly. Data transfer objects, usually called DTOs, provide a stable contract between the server and its clients.
public record ProjectResponse(
int Id,
string Name,
string? Description,
DateTime CreatedUtc);
public record CreateProjectRequest(
string Name,
string? Description);
This separation also helps when requirements change. A project may later gain an internal billing code or an archived flag without forcing clients to understand every database column.
Add Persistence And Validation
The database context defines the relationship between the C# model and SQL Server:
public class TaskDbContext(DbContextOptions<TaskDbContext> options)
: DbContext(options)
{
public DbSet<Project> Projects => Set<Project>();
public DbSet<TaskItem> Tasks => Set<TaskItem>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity<Project>()
.Property(project => project.Name)
.HasMaxLength(200)
.IsRequired();
modelBuilder.Entity<TaskItem>()
.Property(task => task.Title)
.HasMaxLength(300)
.IsRequired();
modelBuilder.Entity<Project>()
.HasMany(project => project.Tasks)
.WithOne(task => task.Project)
.HasForeignKey(task => task.ProjectId)
.OnDelete(DeleteBehavior.Cascade);
}
}
Add a connection string to development configuration:
{
"ConnectionStrings": {
"TaskDatabase": "Server=(localdb)\\mssqllocaldb;Database=TaskApi;Trusted_Connection=True;TrustServerCertificate=True"
}
}
Create and apply a migration:
dotnet ef migrations add InitialCreate
dotnet ef database update
For production deployments, apply migrations through a controlled release process rather than allowing every application startup to modify the database. Database changes should be reviewed, tested and coordinated with application changes.
Request validation belongs at the API boundary. Data annotations work well for simple rules:
public record CreateProjectRequest(
[property: Required, StringLength(200, MinimumLength = 2)]
string Name,
[property: StringLength(2000)]
string? Description);
ASP.NET Core automatically returns a validation response when the controller uses [ApiController]. For a larger domain, FluentValidation or explicit application-layer validators can express more complex rules, such as preventing duplicate project names for the same account.
Australian postcodes are a useful example of why validation should reflect the domain rather than merely check that a value is numeric. A postcode needs four digits, but valid ranges and state-specific rules may matter to a delivery application. Validation should be precise enough to protect the business rule without pretending that every input is globally interchangeable.
Implement Controller Actions
A controller can use constructor injection for the database context:
[ApiController]
[Route("api/[controller]")]
public class ProjectsController(TaskDbContext db) : ControllerBase
{
[HttpGet]
public async Task<ActionResult<IEnumerable<ProjectResponse>>> GetProjects()
{
var projects = await db.Projects
.AsNoTracking()
.OrderBy(project => project.Name)
.Select(project => new ProjectResponse(
project.Id,
project.Name,
project.Description,
project.CreatedUtc))
.ToListAsync();
return Ok(projects);
}
[HttpGet("{id:int}")]
public async Task<ActionResult<ProjectResponse>> GetProject(int id)
{
var project = await db.Projects
.AsNoTracking()
.Where(project => project.Id == id)
.Select(project => new ProjectResponse(
project.Id,
project.Name,
project.Description,
project.CreatedUtc))
.SingleOrDefaultAsync();
return project is null ? NotFound() : Ok(project);
}
}
AsNoTracking() is appropriate for read-only queries because Entity Framework does not need to monitor returned entities. Projecting directly into a DTO also prevents unnecessary columns and related data from being loaded.
A create action should return 201 Created and a location for the new resource:
[HttpPost]
public async Task<ActionResult<ProjectResponse>> CreateProject(
CreateProjectRequest request)
{
var project = new Project
{
Name = request.Name.Trim(),
Description = request.Description?.Trim(),
CreatedUtc = DateTime.UtcNow
};
db.Projects.Add(project);
await db.SaveChangesAsync();
var response = new ProjectResponse(
project.Id,
project.Name,
project.Description,
project.CreatedUtc);
return CreatedAtAction(
nameof(GetProject),
new { id = project.Id },
response);
}
Use UTC for timestamps stored in the database and convert to a user’s local zone at the presentation layer. This avoids ambiguity when clients operate across Sydney, Perth and other regions with different daylight-saving rules. If the business requires an Australian local time, store the relevant time-zone identifier alongside the event rather than relying on a server’s clock.
The update and delete actions should distinguish between a valid operation and a missing resource. Return 204 No Content after a successful update or delete when there is no response body. Return 400 Bad Request for malformed input, 404 Not Found when an identifier does not exist and 409 Conflict when the operation violates a uniqueness or state rule.
Handle Errors, Security And Performance
A production API needs consistent error responses. ASP.NET Core supports Problem Details, which gives clients a standard structure containing a status, title, detail and trace identifier:
builder.Services.AddProblemDetails();
var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();
app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
Avoid returning exception messages or SQL details to callers. Log the full exception on the server, attach a correlation ID to the request and return a safe message to the client. Structured logging with ILogger<T> makes it easier to investigate a failed request without exposing private information.
Authentication answers who the caller is, while authorisation answers what that caller may do. A bearer-token API might register JWT authentication and protect the controller with [Authorize]. For a public service, rate limiting, input limits, HTTPS, secure headers and careful CORS configuration are equally important. CORS should allow the known front-end origins rather than using a permissive wildcard by default.
The Australian Privacy Act and the Australian Privacy Principles are relevant when an API handles personal information. Minimise the data collected, document retention practices and avoid placing names, email addresses or tokens in logs. A local product serving customers in Melbourne or Brisbane still needs a clear approach to privacy, access control and deletion requests.
Large result sets should not be returned in one response. Add filtering, sorting and pagination:
GET /api/projects?page=2&pageSize=25&search=website
Set a sensible maximum page size on the server. For high-volume endpoints, keyset pagination can be more efficient than large offsets. Add database indexes for fields used in filtering and ordering, then inspect generated SQL and query plans before assuming an index will help.
Test And Document The Contract
Swagger provides interactive API documentation during development, but automated tests protect the behaviour behind that documentation. Unit tests can verify validation and domain rules. Integration tests should exercise routing, serialisation, authentication and the database boundary.
A typical integration test uses WebApplicationFactory<Program> to start the application in memory:
public class ProjectsApiTests
: IClassFixture<WebApplicationFactory<Program>>
{
private readonly HttpClient client;
public ProjectsApiTests(WebApplicationFactory<Program> factory)
{
client = factory.CreateClient();
}
[Fact]
public async Task GetProjects_ReturnsSuccess()
{
var response = await client.GetAsync("/api/projects");
response.EnsureSuccessStatusCode();
}
}
For repeatable tests, replace SQL Server with a dedicated test database or a containerised SQL Server instance. SQLite can be useful for some tests, but it does not reproduce every SQL Server behaviour, so it should not be the only integration environment when SQL Server is the production database.
API documentation should describe request bodies, response schemas, authentication requirements, validation failures and examples. OpenAPI generated by Swashbuckle can be published as a contract for a React, Angular or mobile team. Contract tests are particularly valuable when an API serves several clients or external partners in the Australian market.
Test the less obvious cases as carefully as the successful path: an unknown project ID, an empty name, a duplicate record, an unauthorised request, an oversized page size and a database failure. Run these checks in continuous integration before deployment. A team in Sydney and another in Perth should receive the same predictable API behaviour regardless of their local development environment.
Practical Recommendations For A Maintainable API
A first version can be small, but the conventions chosen early affect every future client. Keep controllers focused on HTTP concerns and move business rules into application services or domain classes once the logic becomes substantial. Use asynchronous database methods, cancellation tokens for long-running requests and explicit DTOs at the boundary.
Version the API when a breaking change is unavoidable. A route such as /api/v1/projects is easy for clients to understand, although header-based versioning can also work. Whichever approach you choose, document the support period and provide a migration path rather than silently changing the meaning of an existing response.
The following practices provide a dependable baseline:
- Use nouns for resource routes and HTTP methods for operations.
- Return accurate status codes, especially
201,204,400,404and409. - Validate input at the boundary and enforce important rules again in the domain or database.
- Keep secrets, connection strings and personally identifiable information out of source control and logs.
- Add pagination, filtering, rate limiting and observability before traffic makes them urgent.
- Cover the API with unit, integration and contract tests in continuous integration.
An API should also be measurable in production. Track request duration, error rates, status-code counts, database timings and dependency failures. Health checks can distinguish between a process that is running and an application that can actually reach its database. These signals are useful whether the service runs in an Australian cloud region, a private data centre or a small development environment.
Start by creating the project, model one resource and expose a read-only endpoint. Then add persistence, validation, authentication and tests in deliberate increments. Publish the OpenAPI document alongside the application and let real client feedback guide the next design decision. A carefully shaped ASP.NET Core API can serve a local business today and provide a stable foundation for a much larger platform tomorrow.