ASP.NET Core Web API · Building APIs

Service Layer

Controllers should translate HTTP; services should decide. In this lesson the ShopSmart API’s stock-reservation rules move into a service that returns a Result, and the controller turns that result into 200, 404, 409 or 422 — consistently.

Intermediate 7 min Lesson 11 of 37Examples target .NET 9.
0/37 in ASP.NET Core Web API
On this page · 7 min · Intermediate

In simple words#

The service layer is the kitchen behind the counter: controllers take orders, services do the real cooking (business rules).

Service
Business logic class.
Thin controller
Only HTTP concerns.
Result types
Services tell controllers success, not-found or conflict.

Learning objectives#

  • Move business rules from controllers into services
  • Model expected failures with a Result type
  • Map results to status codes and problem details in one place
  • Keep services free of HTTP concerns

Explanation#

A service method is a use case: ReserveStockAsync, ApplyCouponAsync, CancelOrderAsync. It loads data, checks rules, changes state and saves once.

Expected failures (not found, insufficient stock, already cancelled) are part of normal business. Return them as a Result with an error code and message instead of throwing exceptions.

The controller maps error codes to HTTP: NotFound → 404, Conflict → 409, RuleViolation → 422 — with problem details, so every endpoint answers errors the same way. A small extension method (result.ToActionResult()) keeps this mapping in one place.

Services never touch HttpContext, IActionResult or status codes. That makes them reusable from controllers, minimal APIs, background jobs and tests.

Register services as scoped and inject them into controllers; unit-test them without starting the web host.

Syntax#

C# Illustrative
public record Result<T>(T? Value, ErrorType? Error = null, string? Message = null);
public enum ErrorType { NotFound, Conflict, RuleViolation }


var result = await stock.ReserveAsync(productId, quantity);
return result.ToActionResult(this);       // 200 / 404 / 409 / 422

Code example: A stock service and consistent error mapping#

C#Services/StockService.cs Illustrative
// Program.cs
using System.Net.Http.Json;
using System.Text.Json;


var builder = WebApplication.CreateBuilder(args);
builder.Logging.ClearProviders();                                   // keep the demo output clean
builder.Services.AddControllers();
builder.Services.AddProblemDetails();
builder.Services.AddSingleton<StockService>();                      // in-memory demo; scoped with a DbContext in real apps
var app = builder.Build();
app.MapControllers();
app.Urls.Add("http://127.0.0.1:5211");
await app.StartAsync();


using var http = new HttpClient { BaseAddress = new Uri("http://127.0.0.1:5211") };
foreach (var (productId, quantity) in new[] { (1, 3), (1, 20), (9, 1), (2, 1), (1, 0) })
{
    var response = await http.PostAsJsonAsync("/api/stock/reservations", new { productId, quantity });
    var body = await response.Content.ReadAsStringAsync();
    var text = response.IsSuccessStatusCode ? body : JsonDocument.Parse(body).RootElement.GetProperty("detail").GetString();
    Console.WriteLine(
quot;reserve product
{productId} x{quantity,-2} -> {(int)response.StatusCode} {text}");
} await app.StopAsync(); // Services/Result.cs public enum ErrorType { NotFound, Conflict, RuleViolation } public record Result<T>(T? Value, ErrorType? Error = null, string? Message = null) { public static Result<T> Ok(T value) => new(value); public static Result<T> Fail(ErrorType error, string message) => new(default, error, message); } // Services/StockService.cs public record Reservation(int ProductId, int Quantity, int StockLeft); public class StockService { private readonly Dictionary<int, (int Stock, bool Discontinued)> _products = new() { [1] = (10, false), [2] = (5, true), }; public Task<Result<Reservation>> ReserveAsync(int productId, int quantity) { if (quantity < 1) return Task.FromResult(Result<Reservation>.Fail(ErrorType.RuleViolation, "Quantity must be at least 1.")); if (!_products.TryGetValue(productId, out var product)) return Task.FromResult(Result<Reservation>.Fail(ErrorType.NotFound,
quot;Product
{productId} does not exist."));
if (product.Discontinued) return Task.FromResult(Result<Reservation>.Fail(ErrorType.Conflict,
quot;Product
{productId} is discontinued."));
if (product.Stock < quantity) return Task.FromResult(Result<Reservation>.Fail(ErrorType.RuleViolation,
quot;Only
{product.Stock} left in stock."));
_products[productId] = product with { Stock = product.Stock - quantity }; return Task.FromResult(Result<Reservation>.Ok(new Reservation(productId, quantity, product.Stock - quantity))); } } // Controllers/ResultExtensions.cs using Microsoft.AspNetCore.Mvc; public static class ResultExtensions { public static ActionResult<T> ToActionResult<T>(this Result<T> result, ControllerBase controller) => result.Error switch { null => result.Value!, ErrorType.NotFound => controller.Problem(statusCode: 404, title: "Not found", detail: result.Message), ErrorType.Conflict => controller.Problem(statusCode: 409, title: "Conflict", detail: result.Message), _ => controller.Problem(statusCode: 422, title: "Business rule violated", detail: result.Message), }; } // Controllers/StockController.cs using Microsoft.AspNetCore.Mvc; public record ReserveRequest(int ProductId, int Quantity); [ApiController] [Route("api/stock")] public class StockController(StockService stock) : ControllerBase { [HttpPost("reservations")] public async Task<ActionResult<Reservation>> Reserve(ReserveRequest request) => (await stock.ReserveAsync(request.ProductId, request.Quantity)).ToActionResult(this); }
Expected output
reserve product 1 x3  -> 200 {"productId":1,"quantity":3,"stockLeft":7}
reserve product 1 x20 -> 422 Only 7 left in stock.
reserve product 9 x1  -> 404 Product 9 does not exist.
reserve product 2 x1  -> 409 Product 2 is discontinued.
reserve product 1 x0  -> 422 Quantity must be at least 1.

This example is illustrative. Copy or download it and run it in your own environment; the expected output is shown above.

Step-by-step explanation#

  1. StockService contains every rule and returns a Result — it knows nothing about HTTP.
  2. ToActionResult maps Ok to 200 and each ErrorType to a status code with problem details.
  3. The controller action is one line: call the service, map the result.
  4. Discontinued products are a 409 conflict; too many items or a zero quantity are 422 rule violations; unknown products 404.
  5. The successful reservation reduces stock, and the response shows how much is left.

Real-world use case#

ShopSmart’s Orders, Cart, Coupons and Stock controllers all use ToActionResult, so the React and Angular apps can rely on identical error shapes from every endpoint.

Common mistakes#

Throwing exceptions for expected outcomes

“Out of stock” is not exceptional. Exceptions are slower, hide control flow, and end up as 500s if someone forgets to catch them.

Status codes inside services

A service returning 404 is tied to HTTP and cannot be reused by a background job. Return domain errors; map them at the edge.

Different mappings per controller

One controller returning 400 and another 422 for the same rule confuses clients. Centralise the mapping.

Best practices#

  • One service method per use case
  • Result type for expected failures
  • Central Result → HTTP mapping
  • Unit-test services directly

Remember this#

Service decides, controller translates.

Expected failure → Result. Bug → exception.

NotFound 404 · Conflict 409 · RuleViolation 422.

Practice exercise#

Your task: Add an ErrorType.Forbidden mapped to 403 and a service rule: products with id over 100 are “staff only” and cannot be reserved by customers.

Need a hint?
Extend the enum and the switch; return Result.Fail(ErrorType.Forbidden, "…") from the service.
Show solution
C# Illustrative
// Controllers/ResultExtensions.cs
using Microsoft.AspNetCore.Mvc;


public enum ErrorType { NotFound, Conflict, RuleViolation, Forbidden }


public record Result<T>(T? Value, ErrorType? Error = null, string? Message = null);


public static class ResultExtensions
{
    public static ActionResult<T> ToActionResult<T>(this Result<T> result, ControllerBase controller) => result.Error switch
    {
        null => result.Value!,
        ErrorType.NotFound => controller.Problem(statusCode: 404, detail: result.Message),
        ErrorType.Conflict => controller.Problem(statusCode: 409, detail: result.Message),
        ErrorType.Forbidden => controller.Problem(statusCode: 403, detail: result.Message),
        _ => controller.Problem(statusCode: 422, detail: result.Message),
    };
}


// In StockService.ReserveAsync:
// if (productId > 100) return Task.FromResult(Result<Reservation>.Fail(ErrorType.Forbidden, "Staff-only product."));

Mini quiz#

Service Layer quizMultiple choice Question 1 of 3

Where should the rule “discontinued products cannot be reserved” live?

Score so far: 0/0

Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions

My notes#

Saved in this browser.

Progress is saved in this browser. Sign in to sync.