This episode builds a Web API with ASP.NET Core: Web API project structure, controllers with routing and model binding, response formatting, using HttpClient and typed clients to consume APIs, and API versioning and standard error responses.

Modern backends are almost always APIs — interfaces serving requests from web applications, mobile apps, or other services. ASP.NET Core is .NET's primary web framework, known for high performance and complete tooling.
But a good API isn't just about returning JSON. It needs consistent routing, safe model binding, structured responses, and support for version changes.
Episode 11 builds a Web API from scratch: project structure, controllers and routing, model binding, HttpClient for consuming other APIs, and API versioning and standard error responses.
The Web API template provides a complete structure with OpenAPI and minimal setup:
dotnet new webapi -n Toko.Api -o src/Toko.Api
dotnet add src/Toko.Api package Microsoft.EntityFrameworkCore.SqliteThe dotnet new webapi -n Toko.Api command creates a project with a Program.cs that already contains the minimal pipeline. For new projects, ASP.NET Core recommends the minimal APIs approach, while large projects often use controllers.
A controller is a class that handles HTTP requests and returns responses:
[ApiController]
[Route("api/produk")]
public class ProdukController : ControllerBase
{
private static readonly List<Produk> Produk = new()
{
new Produk { Id = 1, Nama = "Kopi Gayo", Harga = 85_000m }
};
[HttpGet("{id:int}")]
public ActionResult<Produk> GetProduk(int id)
{
var hasil = Produk.FirstOrDefault(p => p.Id == id);
if (hasil is null)
{
return NotFound();
}
return Ok(hasil);
}
[HttpPost]
public ActionResult<Produk> BuatProduk([FromBody] Produk produk)
{
produk.Id = Produk.Count + 1;
Produk.Add(produk);
return CreatedAtAction(nameof(GetProduk), new { id = produk.Id }, produk);
}
}[Route("api/produk")] determines the controller's base path. Model binding fills the int id parameter from the route, and [FromBody] Produk maps the JSON in the request body to a C# object. The [ApiController] attribute enables automatic model validation — invalid requests are rejected before reaching the method.
Built-in content negotiation makes an API return JSON by default. To force a format, use the Accept property in the request, or define the format via Produces:
[HttpGet("{id:int}")]
[Produces("application/json")]
public ActionResult<Produk> GetProdukJson(int id) => Ok(Produk.First(p => p.Id == id));Response helpers like Ok, NotFound, and CreatedAtAction produce the correct HTTP status codes along with a JSON body — this is the foundation of consistent RESTful design.
Backend applications often call other APIs. HttpClient is .NET's primary HTTP client:
using var client = new HttpClient();
client.BaseAddress = new Uri("https://api.exchangerate.host");
var response = await client.GetAsync("/v1/latest?base=USD");
var json = await response.Content.ReadAsStringAsync();
Console.WriteLine(json);An important warning: never create a new HttpClient for every request — sockets will leak and connections will pile up. The solution is typed clients registered in DI.
A typed client abstracts all HTTP details into a single injectable class:
class ExchangeRateClient
{
private readonly HttpClient _http;
public ExchangeRateClient(HttpClient http) => _http = http;
public async Task<string> GetRatesAsync()
{
return await _http.GetStringAsync("/v1/latest?base=USD");
}
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHttpClient<ExchangeRateClient>(c =>
c.BaseAddress = new Uri("https://api.exchangerate.host"));AddHttpClient<ExchangeRateClient> registers a client with an HttpClient that is managed and pooled by the factory — connection pooling, timeouts, and retries are handled automatically.
As an API evolves, old clients must not break. Versioning allows several endpoint versions to coexist:
dotnet add src/Toko.Api package Asp.Versioning.Mvc[ApiController]
[ApiVersion("1.0")]
[Route("api/v{version:apiVersion}/produk")]
public class ProdukController : ControllerBase
{
[HttpGet]
public IActionResult Get() => Ok(new[] { "versi 1" });
}With the pattern above, /api/v1/produk and later versions can coexist without conflicts. Standard error responses follow ASP.NET Core's built-in ProblemDetails format:
{
"type": "https://tools.ietf.org/html/rfc9110",
"title": "Not Found",
"status": 404,
"detail": "Produk dengan id tersebut tidak ditemukan."
}This format is standardized and can be used directly by clients to display error messages.
Key takeaways:
[ApiController] enables automatic model validation.In the next episode 12 we secure the API: security and identity — ASP.NET Core Identity, the authentication pipeline, JWT authentication, authorization policies, OAuth2 and OpenID Connect, and secure headers, CORS, and token management.