A Practical (and Slightly Fun) Guide to Writing APIs That Don't Break at 3 AM
Imagine your API is a restaurant kitchen.
Customers place orders (requests), the kitchen processes them (business logic), and the food is delivered back to the table (responses). If the kitchen is messy, chefs bump into each other, and ingredients are scattered everywhere, orders become slow and mistakes happen.
That’s exactly what happens when APIs are poorly structured.
A scalable API is like a well-organized kitchen:
- Everyone knows their role
- Tools are in the right place
- Orders move smoothly
- And the system works even during rush hour
ASP.NET Core gives us powerful tools to build such APIs. But the real magic comes from how we structure our architecture.
In this article we’ll explore practical patterns that make APIs scalable, clean, and enjoyable to maintain:
- Dependency Injection
- Service–Repository Pattern
- CQRS
- Middleware
- Caching
- Helper Methods
- Factory Methods
Let’s break them down in a simple and human way.
1. The Foundation of a Clean API
A good API architecture usually follows this simple flow:
Request
↓
Controller
↓
Service Layer (Business Logic)
↓
Repository Layer (Database Access)
↓
DatabaseThink of it like a company workflow:
| Layer | Responsibility |
|---|---|
| Controller | Receptionist receiving requests |
| Service | Manager deciding what should happen |
| Repository | Employee interacting with the database |
| Database | Storage room |
Each component has one job, and that keeps the system clean.
2. Dependency Injection – Stop Creating Everything Yourself
One of the biggest mistakes beginners make is manually creating objects everywhere.
Example of the bad approach:
var userService = new UserService();This tightly couples your code and makes testing difficult.
ASP.NET Core solves this using Dependency Injection (DI).
Instead of creating objects, we register them once and let the framework provide them when needed.
Registering Services
builder.Services.AddScoped<IUserService, UserService>();
builder.Services.AddScoped<IUserRepository, UserRepository>();Now ASP.NET Core automatically injects them.
Using DI in a Controller
[ApiController]
[Route("api/users")]
public class UsersController : ControllerBase
{
private readonly IUserService _userService;
public UsersController(IUserService userService)
{
_userService = userService;
}
[HttpGet]
public async Task<IActionResult> GetUsers()
{
var users = await _userService.GetAllUsers();
return Ok(users);
}
}Instead of building objects manually, we let the framework manage them like a smart assistant.
3. Service–Repository Pattern (The Clean Separation)
Imagine putting database queries directly inside controllers.
Soon your controller becomes 500 lines long and impossible to maintain.
This is where the Service–Repository pattern shines.
Repository Layer – Database Interaction
Repositories are responsible only for data access.
public interface IUserRepository
{
Task<IEnumerable<User>> GetAllAsync();
}Implementation:
public class UserRepository : IUserRepository
{
private readonly AppDbContext _context;
public UserRepository(AppDbContext context)
{
_context = context;
}
public async Task<IEnumerable<User>> GetAllAsync()
{
return await _context.Users.ToListAsync();
}
}Service Layer – Business Logic
Services decide how the application behaves.
public interface IUserService
{
Task<IEnumerable<User>> GetAllUsers();
}Implementation:
public class UserService : IUserService
{
private readonly IUserRepository _repo;
public UserService(IUserRepository repo)
{
_repo = repo;
}
public async Task<IEnumerable<User>> GetAllUsers()
{
return await _repo.GetAllAsync();
}
}Controllers now stay small and clean, while services handle the logic.
4. CQRS – Separating Reads and Writes
As applications grow, mixing read and write logic in one service can become messy.
CQRS (Command Query Responsibility Segregation) solves this by separating:
Queries → Read data
Commands → Change dataInstead of a big service doing everything:
UserService
├ GetUser
├ GetUsers
├ CreateUser
├ UpdateUserCQRS splits it.
Query Example (Read Data)
public class GetUsersQuery
{
}Handler:
public class GetUsersHandler
{
private readonly IUserRepository _repo;
public GetUsersHandler(IUserRepository repo)
{
_repo = repo;
}
public async Task<IEnumerable<User>> Handle()
{
return await _repo.GetAllAsync();
}
}Command Example (Write Data)
public class CreateUserCommand
{
public string Name { get; set; }
public string Email { get; set; }
}Handler:
public class CreateUserHandler
{
private readonly AppDbContext _context;
public CreateUserHandler(AppDbContext context)
{
_context = context;
}
public async Task Handle(CreateUserCommand command)
{
var user = new User
{
Name = command.Name,
Email = command.Email
};
_context.Users.Add(user);
await _context.SaveChangesAsync();
}
}Think of CQRS like two separate doors in a store:
- One for entering
- One for exiting
Traffic flows much smoother.
5. Middleware – The Security Guards of Your API
Middleware sits in the request pipeline and processes every request before it reaches the controller.
Common uses:
- Logging
- Error handling
- Authentication
- Request validation
Example: Global Error Middleware
public class ExceptionMiddleware
{
private readonly RequestDelegate _next;
public ExceptionMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
try
{
await _next(context);
}
catch (Exception)
{
context.Response.StatusCode = 500;
await context.Response.WriteAsync("Unexpected server error");
}
}
}Register it:
app.UseMiddleware<ExceptionMiddleware>();Now every exception is handled in one place.
6. Caching – The Secret to Fast APIs
Imagine asking the database the same question 1000 times per minute.
That’s expensive.
Caching stores frequently used data in memory so it can be returned instantly.
Enable Memory Cache
builder.Services.AddMemoryCache();Example Implementation
public class ProductService
{
private readonly IMemoryCache _cache;
private readonly IProductRepository _repo;
public ProductService(IMemoryCache cache, IProductRepository repo)
{
_cache = cache;
_repo = repo;
}
public async Task<IEnumerable<Product>> GetProducts()
{
if (!_cache.TryGetValue("products", out IEnumerable<Product> products))
{
products = await _repo.GetAllAsync();
_cache.Set("products", products,
TimeSpan.FromMinutes(5));
}
return products;
}
}Result:
- Faster responses
- Less database load
- Happier users
7. Helper Methods – Small Tools That Save Time
Helper methods are reusable utilities that prevent duplicate code.
Example: Standard API Response Helper
public static class ResponseHelper
{
public static IActionResult Success(object data)
{
return new OkObjectResult(new
{
success = true,
data = data
});
}
public static IActionResult Fail(string message)
{
return new BadRequestObjectResult(new
{
success = false,
error = message
});
}
}Usage:
return ResponseHelper.Success(users);Helpers make APIs consistent and cleaner.
8. Factory Methods – Smart Object Creation
Sometimes you need to create different objects based on conditions.
Instead of messy if-else logic everywhere, we use a Factory Method.
Example: Notification System.
Interface
public interface INotification
{
void Send(string message);
}Implementations
public class EmailNotification : INotification
{
public void Send(string message)
{
Console.WriteLine($"Email sent: {message}");
}
}
public class SmsNotification : INotification
{
public void Send(string message)
{
Console.WriteLine($"SMS sent: {message}");
}
}Factory
public static class NotificationFactory
{
public static INotification Create(string type)
{
return type switch
{
"email" => new EmailNotification(),
"sms" => new SmsNotification(),
_ => throw new Exception("Invalid type")
};
}
}Usage:
var notifier = NotificationFactory.Create("email");
notifier.Send("Welcome to our platform!");Factories keep object creation organized and flexible.
Final Thoughts
Writing APIs is easy.
Writing APIs that remain clean, scalable, and maintainable years later is the real challenge.
ASP.NET Core gives us incredible tools, but architecture patterns make the real difference.
If you remember just a few things, remember these:
- ✔ Use Dependency Injection to keep components loosely coupled
- ✔ Separate concerns using Service–Repository pattern
- ✔ Use CQRS when applications grow complex
- ✔ Handle cross-cutting logic using Middleware
- ✔ Improve performance with Caching
- ✔ Reduce duplication with Helper methods
- ✔ Manage object creation with Factory methods
When these ideas come together, your API becomes more than just endpoints.
It becomes a well-designed system that developers actually enjoy working with.
And honestly, that’s the real goal of good software engineering.
Happy coding 🚀