Table of Contents
TL;DR:
Most OOP articles throw around the same tired examples: animals, shapes, and bank accounts. While these illustrate concepts, they don’t show you how OOP principles actually manifest in production ASP.NET Core applications.
After building web APIs for the past several years, I’ve learned that understanding OOP in the context of controllers, services, and domain models is what separates junior developers from those who can architect maintainable systems.
Let’s walk through the four pillars as they appear in real codebases, with the messiness and trade-offs included.
Encapsulation: Moving Beyond Property Getters and Setters
Encapsulation isn’t just about making fields private, it’s about hiding behavior and controlling how your objects change state. I see this violated constantly in controllers that directly manipulate domain objects.
Here’s a controller I inherited that violated encapsulation:
[HttpPost("users")]
public async Task<IActionResult> CreateUser(CreateUserRequest request)
{
// Validation scattered everywhere
if (string.IsNullOrEmpty(request.Email) || !request.Email.Contains("@"))
return BadRequest("Invalid email");
if (request.Password.Length < 8)
return BadRequest("Password too short");
var user = new User();
user.Email = request.Email;
user.Password = BCrypt.Net.BCrypt.HashPassword(request.Password);
user.CreatedAt = DateTime.UtcNow;
user.IsActive = true;
await _userRepository.SaveAsync(user);
return Ok();
}
After refactoring with proper encapsulation:
[HttpPost("users")]
public async Task<IActionResult> CreateUser(CreateUserRequest request)
{
var userResult = User.Create(request.Email, request.Password);
if (!userResult.IsSuccess)
return BadRequest(userResult.Error);
await _userRepository.SaveAsync(userResult.Value);
return Ok();
}
public class User
{
private User() { } // Force creation through factory method
public string Email { get; private set; }
public string PasswordHash { get; private set; }
public DateTime CreatedAt { get; private set; }
public bool IsActive { get; private set; }
public static Result<User> Create(string email, string password)
{
if (string.IsNullOrEmpty(email) || !email.Contains("@"))
return Result<User>.Failure("Invalid email format");
if (password.Length < 8)
return Result<User>.Failure("Password must be at least 8 characters");
return Result<User>.Success(new User
{
Email = email,
PasswordHash = BCrypt.Net.BCrypt.HashPassword(password),
CreatedAt = DateTime.UtcNow,
IsActive = true
}
});
}
}
// Result<T> is a simple success/failure wrapper that avoids throwing exceptions for business rule violations
public class Result<T>
{
public bool IsSuccess { get; private set; }
public T Value { get; private set; }
public string Error { get; private set; }
public static Result<T> Success(T value) => new() { IsSuccess = true, Value = value };
public static Result<T> Failure(string error) => new() { IsSuccess = false, Error = error };
}
}
The domain object now controls its own creation and validation. The controller can’t accidentally create invalid users, and the business rules live where they belong.
Now let’s look at how abstraction solves another common pain point in web applications.
Abstraction: Interfaces That Actually Matter
Abstraction in ASP.NET Core shines when you need to swap implementations or mock dependencies. I learned this the hard way when a project needed to switch from Azure Blob Storage to AWS S3 midway through development.
Instead of this tightly coupled nightmare:
public class DocumentController : ControllerBase
{
private readonly BlobServiceClient _blobClient;
[HttpPost("upload")]
public async Task<IActionResult> Upload(IFormFile file)
{
var containerClient = _blobClient.GetBlobContainerClient("documents");
var blobClient = containerClient.GetBlobClient(file.FileName);
using var stream = file.OpenReadStream();
await blobClient.UploadAsync(stream, overwrite: true);
return Ok(new { Url = blobClient.Uri.ToString() });
}
}
I created an abstraction that saved the project:
public interface IStorageService
{
Task<string> UploadAsync(Stream content, string fileName);
Task<Stream> DownloadAsync(string fileName);
Task DeleteAsync(string fileName);
}
public class DocumentController : ControllerBase
{
private readonly IStorageService _storageService;
[HttpPost("upload")]
public async Task<IActionResult> Upload(IFormFile file)
{
using var stream = file.OpenReadStream();
var result = await _storageService.UploadAsync(stream, file.FileName);
return Ok(new { Url = result });
}
}
Now I can inject AzureBlobStorageService
or S3StorageService
implementations without touching the controller. More importantly, my integration tests use InMemoryStorageService
instead of hitting real cloud services.
Remember though: not every dependency needs an interface. Over-abstracting simple, stable dependencies like DateTime.UtcNow
or basic string operations creates unnecessary complexity. Focus abstraction on volatile dependencies, external services, file systems, or anything that changes between environments.
This brings us to inheritance, which many developers reach for too quickly when trying to share code.
Inheritance: When It Becomes a Problem
Early in my career, I thought inheritance was the solution to code reuse. I created elaborate controller hierarchies that seemed elegant at first:
public abstract class BaseController : ControllerBase
{
protected readonly ILogger _logger;
protected readonly IUserService _userService;
protected BaseController(ILogger logger, IUserService userService)
{
_logger = logger;
_userService = userService;
}
protected async Task<User> GetCurrentUser()
{
var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
return await _userService.GetByIdAsync(userId);
}
}
public class OrderController : BaseController
{
private readonly IOrderService _orderService;
public OrderController(ILogger logger, IUserService userService, IOrderService orderService)
: base(logger, userService)
{
_orderService = orderService;
}
}
This inheritance chain became unmaintainable when controllers needed different dependencies or when the base class grew too many responsibilities. The dependency injection container also struggled with the constructor chains, making unit tests painful to set up and leading to brittle constructor hierarchies.
I refactored to composition using the mediator pattern:
public class OrderController : ControllerBase
{
private readonly IMediator _mediator;
public OrderController(IMediator mediator)
{
_mediator = mediator;
}
[HttpGet("{id}")]
public async Task<IActionResult> GetOrder(int id)
{
var query = new GetOrderQuery(id, GetCurrentUserId());
var result = await _mediator.Send(query);
if (!result.IsSuccess)
return BadRequest(result.Error);
return Ok(result.Value);
}
private string GetCurrentUserId() => User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
}
Each handler now has only the dependencies it needs, and the controllers stay thin and focused.
While composition solved the inheritance problem, we still had branching logic scattered throughout the codebase. That’s where polymorphism becomes invaluable.
Polymorphism: Strategy Pattern in Action
Switch statements are code smells that scream for polymorphism. I encountered this in a notification system that grew organically:
public class NotificationService
{
public async Task SendAsync(NotificationRequest request)
{
switch (request.Type)
{
case NotificationType.Email:
// Email sending logic
var emailClient = new SmtpClient();
await emailClient.SendMailAsync(request.Recipient, request.Subject, request.Message);
break;
case NotificationType.SMS:
// SMS logic
var smsClient = new TwilioClient();
await smsClient.SendAsync(request.Recipient, request.Message);
break;
case NotificationType.Push:
// Push notification logic
var pushService = new FirebaseService();
await pushService.SendAsync(request.Recipient, request.Message);
break;
default:
throw new NotSupportedException($"Notification type {request.Type} not supported");
}
}
}
The refactored version uses polymorphism through the strategy pattern:
public interface INotificationStrategy
{
NotificationType Type { get; }
Task SendAsync(string recipient, string subject, string message);
}
public class NotificationService
{
private readonly Dictionary<NotificationType, INotificationStrategy> _strategies;
private readonly ILogger<NotificationService> _logger;
public NotificationService(IEnumerable<INotificationStrategy> strategies, ILogger<NotificationService> logger)
{
_strategies = strategies.ToDictionary(s => s.Type, s => s);
_logger = logger;
}
public async Task<Result> SendAsync(NotificationRequest request)
{
if (!_strategies.TryGetValue(request.Type, out var strategy))
{
_logger.LogWarning("Unsupported notification type: {NotificationType}", request.Type);
return Result.Failure($"Notification type {request.Type} not supported");
}
try
{
await strategy.SendAsync(request.Recipient, request.Subject, request.Message);
return Result.Success();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send {NotificationType} notification to {Recipient}",
request.Type, request.Recipient);
return Result.Failure("Failed to send notification");
}
}
}
}
Adding new notification types now requires only implementing the interface and registering it with DI. No more modifying the core service. The production-ready version handles logging and graceful failure instead of throwing exceptions up the stack.
What to Read Next
If you found these ASP.NET Core OOP patterns useful, you might enjoy these related topics:
- CQRS and MediatR in ASP.NET Core: How the mediator pattern scales beyond simple inheritance problems
- Domain-Driven Design with .NET: Moving business logic out of controllers and into rich domain models
- Dependency Injection Patterns: Advanced DI techniques for clean architecture in web APIs
Conclusion
OOP isn’t a silver bullet, it’s a toolset for managing complexity. In ASP.NET Core applications, these principles help you build systems that are readable, testable, and maintainable over time. Encapsulation keeps your domain logic consistent, abstraction enables flexibility and testing, composition beats inheritance for reusability, and polymorphism eliminates branching logic.
The key is knowing when to apply each principle. Not every class needs elaborate abstractions, and not every conditional deserves the strategy pattern. Focus on the pain points: where your code is hard to test, where changes ripple through multiple files, or where adding features requires modifying existing code. That’s where OOP principles provide the most value.
After years of building and maintaining web APIs, I’ve learned that good OOP isn’t about following rules religiously, it’s about creating code that the next developer (including future you) can understand and extend without fear.
Related Posts
- How to Test ASP.NET Core Middleware: Unit, Integration, and Mocks
- Stop Forcing Unused Methods: Respect ISP
- Prefer Interfaces Over Abstract Classes in C#: Build Flexible, Testable, and Maintainable Code
- Polymorphism in C#: How Template Method, Strategy, and Visitor Patterns Make Your Code Flexible
- Encapsulation Best Practices in C#: Controlled Setters vs Backing Fields