When Design Patterns Become Rituals
There is a peculiar phenomenon that happens in software teams after they reach a certain size or maturity. Design patterns — those hard-won abstractions distilled from decades of engineering experience — quietly shift from being deliberate solutions to being unconscious habits. Nobody decides to stop thinking. It happens gradually, imperceptibly, the way a working process calcifies into bureaucracy. Patterns that were once chosen because they solved something real begin to be applied because they feel right, because the team has always done it this way, because the senior engineer mentioned it in an onboarding session two years ago. In the .NET and ASP.NET world, where the framework is rich, opinionated, and deeply integrated, this tendency has particularly fertile ground to grow in.
Pattern-First Thinking
Many developers working with ASP.NET Core have experienced this without quite naming it: a new feature request arrives, and before the problem is fully understood, the mind begins mapping it to known structures. "This looks like a Repository." "We should probably add a Factory here." "Let's wrap this in a Service layer." The instinct isn't wrong — it comes from experience, from having seen similar problems before. But it can arrive too early, before the specific contours of the current problem have been taken seriously.
This is where patterns begin to resemble rituals. They are performed because they are expected, not because they are needed. A simple CRUD operation backed by Entity Framework Core might be wrapped in multiple layers — repositories, unit of work abstractions, service classes — each adding indirection without adding meaning. The code becomes ceremonious rather than expressive.
The Repository Pattern Problem
Consider the ubiquitous Repository pattern. In many ASP.NET applications, it appears almost by default, regardless of what the system actually needs. A typical implementation wraps Entity Framework Core in an interface that feels clean and well-structured at first glance:
public interface IProductRepository
{
Task<Product?> GetByIdAsync(int id);
Task<IEnumerable<Product>>
GetAllAsync();
}
public class ProductRepository :
IProductRepository
{
private readonly AppDbContext _context;
public ProductRepository(AppDbContext context)
=> _context = context;
public async Task<Product?>
GetByIdAsync(int id) =>
await _context.Products.FindAsync(id);
public async Task<IEnumerable
<Product>> GetAllAsync() =>
await _context.Products.ToListAsync();
}
But Entity Framework Core is already an abstraction. The DbContext already implements the Unit of Work pattern. The DbSet<T> is, in a meaningful sense, already a repository. Wrapping it in yet another layer doesn't reduce complexity — it doubles the surface area of the data access layer while gaining almost nothing architecturally. The extra indirection now needs its own tests, its own maintenance, its own documentation.
Worse, the wrapping often strips away the expressiveness of LINQ, forcing teams into anemic method signatures that can't accommodate the nuance of real queries. The pattern is applied not because the domain demands it, but because "this is how we always do it."
// What was already available
public class ProductService
{
private readonly AppDbContext _context;
// Query directly, with
// full EF Core expressiveness
public async Task<Product?>
GetFeaturedAsync() =>
await _context.Products
.Where(p => p.IsFeatured && p.Stock > 0)
.OrderByDescending(p => p.CreatedAt)
.FirstOrDefaultAsync();
}
The Repository pattern makes genuine sense when you need to decouple domain logic from persistence concerns, support multiple data sources, or operate in a domain complex enough to warrant it. Used blindly, it is ceremony.
The Service Layer as a Dumping Ground
The Service layer is another pattern with a troubled existence in many ASP.NET codebases. In theory, services encapsulate business logic and provide a coherent API for controllers or other consumers. In practice, they frequently become massive, shapeless classes where logic accumulates through the path of least resistance.
The OrderService starts reasonable — PlaceOrderAsync, CancelOrderAsync, a handful of focused methods. Six months later it has grown to encompass inventory checks, email dispatch, PDF generation, currency formatting, loyalty point calculation, and a helper method someone added because they weren't sure where else to put it. And its constructor tells the whole story:
public class OrderService
{
private readonly
IOrderRepository _orderRepository;
private readonly
IPaymentGateway _paymentGateway;
private readonly
INotificationService _notificationService;
private readonly
IInventoryService _inventoryService;
private readonly
ILogger<OrderService> _logger;
private readonly
IPdfGenerator _pdfGenerator;
public OrderService(
IOrderRepository orderRepository,
IPaymentGateway paymentGateway,
INotificationService notificationService,
IInventoryService inventoryService,
ILogger<OrderService> logger,
IPdfGenerator pdfGenerator)
{ ... }
}
This is what might be called dependency inflation — classes that have become coordinators of other abstractions rather than encapsulations of meaningful behavior. The constructor injection system in ASP.NET Core is powerful and promotes testability, but when applied indiscriminately it produces object graphs that are deeply nested and hard to reason about. Six injected dependencies isn't a design. It's a symptom.
The Service pattern survives in this form because teams treat it as a container rather than a discipline. The pattern's purpose is cohesion: grouping things that genuinely belong together. When it becomes a ritual, it becomes a location instead of a principle, and things accumulate in it the way objects accumulate on a desk because that's where the first one happened to land.
Abstraction as Reflex
The ritualization of patterns is perhaps most visible in how teams reach for interfaces and abstractions even when neither testing nor extensibility demands them. The built-in dependency injection container in ASP.NET Core makes it trivially easy to register and inject services, which is a genuine strength of the framework — but it can quietly encourage a kind of abstraction by default.
Consider a class introduced purely to make time-dependent logic testable:
public interface IDateTimeProvider
{
DateTime UtcNow { get; }
}
public class DateTimeProvider :
IDateTimeProvider
{
public DateTime UtcNow => DateTime.UtcNow;
}
This abstraction is sometimes entirely justified — isolating DateTime.UtcNow behind an interface is a legitimate approach when unit testing time-sensitive logic. But when the same impulse is applied across the codebase indiscriminately, teams end up maintaining a sprawling catalogue of single-implementation interfaces that exist not because substitution is ever needed, but because the team has learned to equate interfaces with professionalism. Every class becomes wrapped, every dependency injected, and the cognitive overhead of navigating the codebase grows — not because the system is complex, but because the architecture performs complexity it doesn't have.
The Cargo Cult of Architecture
Over time, teams begin to equate good architecture with the presence of patterns rather than the clarity of solutions. This leads to what might fairly be called architectural cargo culting — replicating structures without understanding their purpose. The Factory pattern is a recurring example. Introduced to manage genuinely complex object creation, it frequently appears in codebases where no such complexity exists:
public interface INotificationFactory
{
INotification Create(string type);
}
public class NotificationFactory :
INotificationFactory
{
public INotification Create
(string type) => type switch
{
"email" => new EmailNotification(),
"sms" => new SmsNotification(),
_ => throw new
ArgumentException("Invalid type")
};
}
If the system supports exactly two notification types and that number is unlikely to grow, this abstraction carries its costs without delivering its benefits. The factory becomes valuable when object creation logic is genuinely complex or subject to change. When neither is true, it is another layer to maintain, another indirection to trace, another interface to mock in tests that are themselves testing nothing meaningful.
Mediator and the Ceremony of Indirection
More recently, the Mediator pattern — popularized in ASP.NET applications through the MediatR library — has acquired a similar ritual quality. The argument for it is compelling: commands and queries become self-contained objects, handlers are single-responsibility, controllers become thin, and cross-cutting concerns like logging and validation can be applied through a pipeline of behaviors.
public record GetProductQuery(int Id) :
IRequest<ProductDto>;
public class GetProductQueryHandler :
IRequestHandler<GetProductQuery, ProductDto>
{
private readonly AppDbContext _context;
public GetProductQueryHandler
(AppDbContext context) => _context = context;
public async Task<ProductDto>
Handle(GetProductQuery request,
CancellationToken ct)
{
var product = await _context.
Products.FindAsync(request.Id, ct);
return product is null ?
throw new NotFoundException() :
product.ToDto();
}
}
For a sufficiently large application with genuine cross-cutting complexity, this is a beautiful design. But the pattern has spread far beyond those boundaries. Teams apply it uniformly because the project template includes it, because it looks like good architecture. The result is applications where every trivial operation — fetching a single record, toggling a boolean flag — is wrapped in a request class, a handler class, and the overhead of mediator dispatch. The cognitive overhead of tracing a feature through this indirection is substantial. What was designed to manage complexity has itself become a source of it.
How Patterns Lose Their Judgment
The common thread across all of these examples is the absence of the question that should always precede a pattern's adoption: what problem am I actually solving right now? Patterns are not inherently good. They are trade-offs — they accept some costs in exchange for specific benefits. When teams apply them ritually, they accept the costs unconditionally, regardless of whether the benefits are relevant to their situation. The principle of YAGNI — You Aren't Gonna Need It — exists precisely for this: resist adding complexity until the need is evident, not anticipated.
This is partly a documentation failure. Patterns get taught as solutions, but rarely as trade-offs. What arrives at the team level is often just the pattern itself, stripped of its preconditions. New developers learn the how but not the why. Code reviews reinforce consistency rather than intent. Scaffolding tools further accelerate this drift by generating pattern-heavy code automatically — you get the structure before you've asked whether you need it.
It is also a social phenomenon. Proposing not to use a familiar pattern requires more justification than using it. The burden of proof falls asymmetrically. "Why aren't you using a repository here?" demands an answer. "Why are you using a repository here?" often does not.
Regaining Intentionality
The antidote is not cynicism about patterns — they encode real wisdom, and dismissing them wholesale trades one failure mode for another. The antidote is restoring the question. Before reaching for the Repository, ask what the actual testing and abstraction requirements are. Before adding a Service class, ask what specific cohesion it represents. Before wiring in a Mediator, ask whether the application's scale and cross-cutting needs genuinely warrant the indirection.
In ASP.NET Core specifically, the framework already makes many of the right things easy. Minimal APIs reduce controller ceremony. EF Core handles persistence abstraction. Dependency injection is first-class. The framework, used thoughtfully, provides structure without demanding additional pattern layering on top. Starting with straightforward code and introducing patterns only when the need becomes evident isn't a shortcut — it's the more disciplined path.
Good engineering is not the absence of patterns. It is the presence of judgment — the ongoing willingness to ask whether the tool fits the problem, rather than letting the comfort of familiarity substitute for that question. When patterns are chosen deliberately, they are architecture. When they are applied automatically, they are ritual. The difference, in a large codebase over time, is enormous. And ultimately, the question worth returning to is not whether your code follows established patterns — it is whether those patterns are serving your code, or whether your code has begun to serve the patterns.
That’s all for now. May your intention be clear and your mind be still. With this quiet wish, I rest my pen and return to the silence.