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.
On this page · 7 min · Intermediate
On this page
- In simple words
- Learning objectives
- Explanation
- Syntax
- Code example
- Step by step
- Real-world use
- Common mistakes
- Best practices
- Remember this
- Practice exercise
- Mini quiz
- Related topics
- My notes
- Reading time
- 7 min
- Difficulty
- Intermediate
- Updated
- Oct 29, 2026
Related tutorials
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#
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#
// 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);
}
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#
- StockService contains every rule and returns a Result — it knows nothing about HTTP.
- ToActionResult maps Ok to 200 and each ErrorType to a status code with problem details.
- The controller action is one line: call the service, map the result.
- Discontinued products are a 409 conflict; too many items or a zero quantity are 422 rule violations; unknown products 404.
- 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#
“Out of stock” is not exceptional. Exceptions are slower, hide control flow, and end up as 500s if someone forgets to catch them.
A service returning 404 is tied to HTTP and cannot be reused by a background job. Return domain errors; map them at the edge.
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?
Show solution
// 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#
Where should the rule “discontinued products cannot be reserved” live?
Want more? Practise all ASP.NET Core Web API MCQs · ASP.NET Core Web API interview questions
My notes#
Saved in this browser.