Overview
A software design pattern is a reusable approach to a recurring design problem. Patterns give developers a shared vocabulary and a tested way to organize responsibilities, dependencies, object creation, communication, or system boundaries. Examples include Strategy, Factory, Adapter, Decorator, Repository, Mediator, CQRS, and event-driven communication.
A pattern is valuable when it addresses a real force in the system. It might isolate a dependency that changes frequently, make several algorithms interchangeable, protect the domain from an external API, centralize a cross-cutting policy, or allow teams to change separate parts of a system independently.
The same pattern can become harmful when it is introduced without a matching problem. Every abstraction has a cost:
- More types, files, interfaces, and configuration.
- More indirection when tracing behavior.
- More concepts for developers to learn.
- More integration points and failure modes.
- More tests and documentation to maintain.
- More restrictions on future changes.
The goal is not to use as many patterns as possible. The goal is to make the system easier to understand, change, test, operate, and extend for its actual requirements.
This judgment matters in .NET applications because modern frameworks already provide many abstractions. ASP.NET Core includes dependency injection, middleware, filters, configuration, logging, and hosted services. Entity Framework Core already implements repository-like and unit-of-work behavior. Adding custom layers over these capabilities can improve a domain-focused design, but it can also produce pass-through code that hides useful framework features without creating a meaningful boundary.
This topic appears frequently in interviews because it reveals engineering maturity. Memorizing pattern definitions is not enough. Strong candidates can explain:
- What problem a pattern solves.
- Which forces make the pattern appropriate.
- What complexity the pattern introduces.
- Which simpler alternatives were considered.
- How the design can evolve incrementally.
- How the team will know whether the pattern is helping.
The central interview principle is:
Choose a pattern because its problem and trade-offs match the system,
not because the pattern is popular or technically interesting.
Core Concepts
Patterns Are Contextual Solutions, Not Universal Rules
A useful way to discuss a pattern is:
Context -> Problem -> Forces -> Solution -> Consequences
- Context describes the environment in which the problem occurs.
- Problem describes the recurring design difficulty.
- Forces are competing requirements or constraints.
- Solution describes the structure and interactions proposed by the pattern.
- Consequences include both benefits and costs.
For example, Strategy is not simply "put every conditional branch behind an interface." Its context usually includes multiple algorithms or policies that vary independently from the code that uses them.
The forces might include:
- New policies are added regularly.
- Each policy has substantial behavior.
- Policies need independent tests.
- The caller should not depend on policy details.
- Policies might be selected at runtime.
If only one short calculation exists and no variation is expected, Strategy might add ceremony without reducing meaningful change cost.
What Maintainability Means
Maintainability is broader than having small classes or many interfaces. A maintainable system supports safe and economical change.
Important dimensions include:
- Understandability: Developers can follow the behavior without excessive navigation.
- Modifiability: A requirement can be changed in a focused area.
- Testability: Important behavior can be verified with appropriate tests.
- Replaceability: External details can be changed without rewriting core policy.
- Diagnosability: Failures can be traced through logs, metrics, and clear control flow.
- Consistency: Similar problems are solved in predictable ways.
- Operability: Deployment, monitoring, recovery, and support remain manageable.
- Onboarding cost: New developers can build an accurate mental model.
A pattern improves maintainability when its reduction in coupling, duplication, or change risk is greater than the complexity it introduces.
Essential Complexity and Accidental Complexity
Essential complexity comes from the problem domain. Payment authorization, inventory allocation, tax calculation, permissions, retries, and distributed consistency can be genuinely complex.
Accidental complexity comes from the chosen solution. It includes unnecessary layers, generic frameworks, excessive configuration, duplicated mapping, distributed communication, and abstractions that do not correspond to real domain concepts.
A good pattern helps manage essential complexity. A poorly chosen pattern adds accidental complexity.
Example:
Essential complexity:
Different countries have legally different tax rules.
Helpful design:
Separate tax policies with explicit tests.
Accidental complexity:
A generic rules engine, plug-in loader, expression parser,
and distributed policy service when only two stable rules exist.
The Complexity Budget
Every system has a limited complexity budget. Developers spend that budget on domain rules, reliability, security, performance, deployment, and integration.
Using a pattern consumes part of the budget through:
- Additional abstractions.
- Additional runtime interactions.
- Additional configuration.
- Additional operational dependencies.
- Additional knowledge required from the team.
Complexity is justified when it purchases a more valuable property, such as isolation from a volatile dependency, independent scaling, stronger consistency, safer extension, or clearer ownership.
An interview answer should make both sides explicit:
This pattern adds one level of indirection and more types,
but it isolates a frequently changing policy and lets us test
and deploy implementations independently.
Signals That a Pattern Is Likely to Help
A pattern is more likely to improve maintainability when one or more of these signals are present.
Repeated and Meaningful Variation
Several implementations perform the same role but differ in behavior.
Examples:
- Multiple payment providers.
- Several pricing policies.
- Different export formats.
- Environment-specific storage implementations.
- Multiple authentication mechanisms.
The variation should be meaningful. Creating an interface for two classes that differ only by one constant might not provide enough value.
A Volatile Dependency
An external system, vendor API, framework, or infrastructure technology changes independently from the domain.
An Adapter or anti-corruption layer can protect the rest of the application from:
- Vendor-specific request and response models.
- Unstable SDK behavior.
- Authentication details.
- External naming and data conventions.
- Migration between old and new systems.
A Stable Business Boundary
The pattern expresses a stable concept in the domain, such as:
IPricingPolicyPaymentMethodOrderRepositoryShippingQuoteFraudDecision
Stable domain concepts are better abstraction candidates than speculative technical concepts such as IGenericProcessor<TInput, TOutput>.
Independent Change or Ownership
A boundary can help when different teams, modules, or deployment units need to evolve independently. This is especially relevant for modules, services, plug-ins, and external integrations.
The boundary should reflect an actual ownership or lifecycle difference. Splitting code into services without independent ownership or deployment needs usually creates network and operational costs without enough benefit.
A Cross-Cutting Policy
Decorator, middleware, pipeline, or proxy patterns can help when the same policy must be applied consistently:
- Authorization.
- Validation.
- Logging.
- Caching.
- Retries.
- Metrics.
- Transaction behavior.
The pattern should centralize a real policy rather than hide ordinary application flow.
A Known Quality Requirement
Patterns can be justified by measurable nonfunctional requirements:
- Read and write workloads need independent scaling.
- A dependency requires fault isolation.
- Requests require idempotency.
- A legacy system must be replaced incrementally.
- A long-running operation must survive process restarts.
These are stronger reasons than "we may need flexibility later."
Signals That a Pattern Is Probably Overengineering
The Design Solves Only a Hypothetical Future
Speculative extension points are often based on an inaccurate prediction of future requirements. They create a maintenance cost immediately while their benefit may never arrive.
Examples:
- A plug-in framework with one built-in implementation.
- A generic rule engine for one validation rule.
- Multi-database abstractions when the application has no migration requirement.
- Event sourcing added because an audit screen might be requested later.
There Is Only One Trivial Implementation
An interface with one implementation is not automatically wrong. Interfaces can define boundaries to external resources or important domain ports. However, a one-to-one interface and class pair that merely forwards calls is a warning sign.
public interface ICurrentTimeService
{
DateTime GetUtcNow();
}
public sealed class CurrentTimeService : ICurrentTimeService
{
public DateTime GetUtcNow() => DateTime.UtcNow;
}
This abstraction might be justified if time is a tested dependency used throughout domain logic. It is unnecessary if it exists only because every class in the project is required to have an interface.
Modern .NET also provides TimeProvider, which may remove the need for a custom abstraction.
The Pattern Produces Pass-Through Layers
A pass-through layer repeats another API without adding policy, translation, ownership, or protection.
public sealed class ProductRepository(AppDbContext db)
{
public Task<Product?> GetByIdAsync(
int id,
CancellationToken cancellationToken)
{
return db.Products.FindAsync([id], cancellationToken).AsTask();
}
public void Add(Product product)
{
db.Products.Add(product);
}
}
This repository may be useful if it represents an aggregate boundary and will contain domain-specific persistence rules. If every method simply mirrors DbSet<T>, the layer might hide EF Core capabilities while adding no useful abstraction.
Simple Behavior Requires Excessive Navigation
If understanding one request requires opening an endpoint, handler, command, dispatcher, pipeline behavior, service, repository, specification, mapper, and factory, the architecture may be optimized for structural purity rather than comprehension.
Indirection is valuable when it isolates change. Indirection without a clear boundary increases cognitive load.
The Abstraction Uses Generic Technical Language
Names such as Manager, Processor, Helper, Engine, and Handler can indicate that the abstraction has no clear responsibility.
Compare:
IProcessor<Order, Result>
with:
IPaymentAuthorizationPolicy
The second contract communicates a domain capability and its reason to change.
The Framework Already Provides the Needed Pattern
Custom infrastructure should not duplicate mature framework behavior without a specific requirement.
Examples in ASP.NET Core and .NET include:
- Built-in dependency injection.
- Middleware pipelines.
- Endpoint filters and MVC filters.
- Options and configuration.
- Logging abstractions.
HttpClientFactory.TimeProvider.- Hosted services.
- EF Core change tracking and transactions.
Wrapping these features can be appropriate when creating a domain boundary, but wrapping them only to avoid a direct framework reference can add ceremony.
A Practical Pattern Decision Framework
Use the following questions before introducing a pattern.
What Concrete Problem Exists?
Describe the current problem without naming a pattern.
Weak:
We need Strategy because Strategy is cleaner.
Stronger:
Shipping cost is selected by destination and contract type.
Four policies change independently, and each has different external dependencies.
The current switch changes every sprint and has caused pricing regressions.
What Evidence Shows the Problem Matters?
Useful evidence includes:
- Repeated changes to the same conditional.
- Defects caused by duplicated policy.
- Difficult or slow tests.
- Merge conflicts between teams.
- A measurable performance bottleneck.
- An unreliable external dependency.
- Repeated production incidents.
- A confirmed requirement for another implementation.
What Is the Simplest Viable Alternative?
Consider alternatives before selecting a full pattern:
- A private method.
- A data structure.
- A lookup table.
- A function or delegate.
- A direct framework feature.
- A focused class.
- A small module boundary.
- Duplication that is not yet a stable abstraction.
What Benefit Does the Pattern Purchase?
Name the expected benefit:
- Localized change.
- Independent testing.
- Replaceable infrastructure.
- Fault isolation.
- Consistent policy.
- Independent deployment.
- Optimized read behavior.
- Incremental migration.
What Costs Does It Introduce?
Evaluate:
- More code and types.
- Runtime overhead.
- Operational services.
- Eventual consistency.
- Mapping and synchronization.
- Debugging across boundaries.
- Team learning.
- Migration cost.
- Lock-in to the abstraction.
Is the Decision Reversible?
For reversible decisions, prefer learning from a simple implementation.
Examples:
- Extracting a Strategy from a switch later is usually manageable.
- Moving a private method into a class is usually manageable.
For expensive or difficult-to-reverse decisions, perform more design work:
- Splitting a database across services.
- Choosing event sourcing as the source of truth.
- Publishing a public API contract.
- Selecting a partition key.
- Committing to an external vendor protocol.
Can the Pattern Be Introduced Incrementally?
Prefer the smallest form that solves the current problem.
Examples:
- Separate command and query classes before using separate databases.
- Introduce one Adapter at an external boundary before building a generic integration framework.
- Extract one Strategy family after variation becomes clear.
- Start with a modular monolith before distributing modules as microservices.
Strategy Pattern: When It Helps
Consider pricing behavior that is growing:
public decimal CalculatePrice(Order order, Customer customer)
{
if (customer.IsEmployee)
{
return order.Subtotal * 0.70m;
}
if (customer.IsPremium)
{
return order.Subtotal * 0.90m;
}
return order.Subtotal;
}
This switch is acceptable when:
- The rules are short.
- The rules change together.
- New rules are rare.
- The method remains easy to understand.
Strategy becomes useful when each policy grows, requires dependencies, or changes independently.
public interface IPricingPolicy
{
bool AppliesTo(Customer customer);
decimal CalculatePrice(Order order);
}
public sealed class EmployeePricingPolicy : IPricingPolicy
{
public bool AppliesTo(Customer customer) => customer.IsEmployee;
public decimal CalculatePrice(Order order) => order.Subtotal * 0.70m;
}
public sealed class PremiumPricingPolicy : IPricingPolicy
{
public bool AppliesTo(Customer customer) => customer.IsPremium;
public decimal CalculatePrice(Order order) => order.Subtotal * 0.90m;
}
public sealed class PricingService(IEnumerable<IPricingPolicy> policies)
{
public decimal CalculatePrice(Order order, Customer customer)
{
IPricingPolicy? policy = policies.FirstOrDefault(
candidate => candidate.AppliesTo(customer));
return policy?.CalculatePrice(order) ?? order.Subtotal;
}
}
The pattern now purchases:
- Independent policy tests.
- Focused policy dependencies.
- Localized changes.
- Runtime selection.
- Easier addition of genuinely new policies.
It also adds:
- Multiple types.
- Selection-order concerns.
- Possible ambiguity when several policies apply.
- More navigation.
Those consequences must be managed through explicit precedence, validation, or a different selection model.
Delegates Before Full Strategy Classes
Not every interchangeable behavior needs a class hierarchy. A delegate can be enough for local behavior.
public static decimal CalculatePrice(
Order order,
Func<Order, decimal> pricingRule)
{
return pricingRule(order);
}
decimal total = CalculatePrice(
order,
currentOrder => currentOrder.Subtotal * 0.90m);
Use a delegate when:
- The behavior is small and local.
- No complex dependencies are required.
- A named domain type would not improve communication.
Use Strategy classes when:
- Implementations have meaningful domain identities.
- Implementations require dependencies or state.
- Policies need independent lifecycle or discovery.
- The contract is shared across modules.
Factory Pattern: Construction Complexity Must Be Real
Direct construction is usually clearest:
var formatter = new CsvReportFormatter();
A factory helps when creation involves real policy:
- Selecting an implementation from runtime data.
- Validating incompatible options.
- Coordinating several dependencies.
- Hiding a third-party construction API.
- Managing object pooling or lifecycle.
public sealed class ReportFormatterFactory(
CsvReportFormatter csv,
PdfReportFormatter pdf)
{
public IReportFormatter Create(ReportFormat format) =>
format switch
{
ReportFormat.Csv => csv,
ReportFormat.Pdf => pdf,
_ => throw new NotSupportedException(
$"Report format '{format}' is not supported.")
};
}
A factory is unnecessary when it only moves one new expression into another class.
Repository Pattern with Entity Framework Core
EF Core's DbContext and DbSet<T> already provide repository-like and unit-of-work capabilities. A generic repository that mirrors these APIs can reduce expressiveness.
public interface IRepository<T>
{
IQueryable<T> GetAll();
Task<T?> GetByIdAsync(int id);
void Add(T entity);
void Update(T entity);
void Delete(T entity);
}
Common problems with this abstraction include:
- It exposes
IQueryable<T>, so persistence details still leak. - It hides useful EF Core features.
- It creates a lowest-common-denominator API.
- It adds one interface and implementation for every entity.
- It often exists mainly to make mocking easier.
A focused repository can be justified when it represents a domain boundary:
public interface IOrderRepository
{
Task<Order?> GetForFulfillmentAsync(
OrderId orderId,
CancellationToken cancellationToken);
Task<bool> HasOpenOrderForCustomerAsync(
CustomerId customerId,
CancellationToken cancellationToken);
void Add(Order order);
}
This contract communicates business intent rather than CRUD operations. It can protect aggregate invariants and isolate persistence-specific queries.
Mediator and CQRS: Use the Smallest Useful Form
Mediator can reduce direct coupling between request senders and handlers. Pipeline behaviors can centralize validation, authorization, logging, or transactions.
It can help when:
- The application has many use cases with consistent pipelines.
- Commands and queries need clear ownership.
- Handlers form meaningful application boundaries.
- Cross-cutting behavior would otherwise be duplicated.
It can hurt when:
- Every endpoint simply forwards to a one-line handler.
- Control flow becomes difficult to trace.
- The application is small and CRUD-focused.
- Reflection, registration, or conventions obscure dependencies.
CQRS also exists on a spectrum:
Level 1: Separate command and query methods.
Level 2: Separate command and query models.
Level 3: Separate processing pipelines.
Level 4: Separate read and write data stores.
Level 5: Add asynchronous projections or event sourcing.
Do not jump to the most complex level when a simpler separation solves the problem. Separate data stores introduce synchronization, messaging, retries, stale reads, and operational work.
Adapter Pattern at External Boundaries
Adapter is usually valuable when an external API model should not spread through the application.
public interface IPaymentGateway
{
Task<PaymentAuthorization> AuthorizeAsync(
Money amount,
PaymentMethod paymentMethod,
CancellationToken cancellationToken);
}
public sealed class VendorPaymentGateway(
VendorPaymentClient client) : IPaymentGateway
{
public async Task<PaymentAuthorization> AuthorizeAsync(
Money amount,
PaymentMethod paymentMethod,
CancellationToken cancellationToken)
{
VendorAuthorizationResponse response =
await client.CreateAuthorizationAsync(
new VendorAuthorizationRequest
{
AmountInMinorUnits = amount.ToMinorUnits(),
CurrencyCode = amount.Currency,
Token = paymentMethod.Token
},
cancellationToken);
return new PaymentAuthorization(
response.AuthorizationId,
response.Approved,
response.DeclineReason);
}
}
The adapter provides:
- Translation between external and internal models.
- Isolation from vendor naming and SDK changes.
- A clear place for vendor-specific error handling.
- A stable domain-facing contract.
This is stronger than creating an interface around every internal class because the external boundary is independently volatile.
Decorator, Middleware, and Pipeline Patterns
Decorator adds behavior around an operation while preserving the same contract.
public sealed class CachingProductReader(
IProductReader inner,
IMemoryCache cache) : IProductReader
{
public Task<ProductView?> GetAsync(
ProductId id,
CancellationToken cancellationToken)
{
return cache.GetOrCreateAsync(
$"product:{id}",
entry =>
{
entry.AbsoluteExpirationRelativeToNow =
TimeSpan.FromMinutes(5);
return inner.GetAsync(id, cancellationToken);
});
}
}
Decorator is useful when:
- The behavior applies to a specific contract.
- Layers can be composed predictably.
- The original implementation should remain focused.
ASP.NET Core middleware is often better when behavior applies to the HTTP request pipeline globally. MVC or endpoint filters may be better when behavior is tied to actions or endpoints.
Choose the narrowest mechanism matching the scope of the concern.
Microservices as an Example of Pattern Overreach
Microservices can provide:
- Independent deployment.
- Independent scaling.
- Fault isolation.
- Team autonomy.
- Technology flexibility.
They also introduce:
- Network latency and partial failure.
- Distributed tracing.
- Deployment coordination.
- Message delivery concerns.
- Eventual consistency.
- Versioned contracts.
- More infrastructure and operational ownership.
Microservices are justified when service boundaries align with business capabilities, teams can own services independently, and independent deployment or scaling has real value.
They are usually excessive for a small team, a simple domain, or an application without mature deployment and observability practices. A modular monolith often preserves domain boundaries with much lower operational cost and leaves open the option to extract services later.
Duplication vs the Wrong Abstraction
DRY does not mean every repeated line must be unified. Two pieces of code can look similar while representing different concepts that change for different reasons.
Prematurely combining them creates coupling:
public static decimal CalculateFee(
decimal amount,
decimal percentage)
{
return amount * percentage;
}
This function might be reused for tax, payment fees, and employee discounts because the current arithmetic is identical. However, those policies have different meanings, rules, rounding requirements, and reasons to change.
Some duplication is safer until the shared concept becomes clear.
A strong abstraction has:
- A clear name.
- A stable responsibility.
- A contract based on consumer needs.
- Multiple meaningful uses or a strong boundary reason.
- Less knowledge of implementation details.
- A lower total change cost than the duplicated code.
The Rule of Three
The Rule of Three is a heuristic:
Implement the first case directly.
Tolerate a second similar case while comparing how it changes.
Extract an abstraction when a third case reveals the stable common concept.
It is not a law. Extract earlier when:
- Security or compliance policy must be centralized.
- An external dependency needs isolation.
- Duplication has already caused defects.
- A public contract must be designed carefully.
Wait longer when:
- Requirements are still being discovered.
- Similar code represents different business concepts.
- The proposed abstraction requires many configuration options.
YAGNI and Evolutionary Design
YAGNI means avoiding capabilities and abstractions that serve only speculative future needs. It does not mean ignoring code quality.
Healthy evolutionary design combines:
- Simple current implementation.
- Automated tests.
- Continuous refactoring.
- Small changes.
- Clear boundaries where evidence supports them.
- Monitoring and feedback.
Refactoring is not a failure to design ahead. It is how the design incorporates knowledge gained from real requirements.
The key distinction is:
Improve the code's ability to change,
but do not implement hypothetical variations before they are needed.
Reversibility and the Cost of Delay
Pattern decisions should consider when information becomes available.
Introducing an abstraction too early has:
- Implementation cost.
- Delayed delivery of current value.
- Ongoing maintenance cost.
- Risk of choosing the wrong abstraction.
Waiting too long can have:
- Migration cost.
- Duplicated defects.
- Contract-breaking changes.
- Data migration or operational risk.
For reversible code-level choices, waiting for evidence is often safer. For public APIs, data ownership, partitioning, security boundaries, and externally published events, early deliberate design may prevent expensive changes.
Testing Is Not Enough by Itself to Justify an Abstraction
Creating interfaces only so every dependency can be mocked can lead to:
- Tests coupled to implementation details.
- Large mock setups.
- False confidence because real integrations are not exercised.
- Interfaces with no domain meaning.
Use the appropriate test level:
- Unit-test complex domain policy.
- Use fakes for stable boundaries when useful.
- Use integration tests for EF Core, HTTP, queues, and framework pipelines.
- Use end-to-end tests for critical user workflows.
An abstraction should primarily improve the production design. Testability is an important consequence, not always the sole reason for its existence.
Measuring Whether a Pattern Helps
Pattern value should be evaluated after adoption.
Useful qualitative questions:
- Can developers find the relevant behavior quickly?
- Does a change stay within one module?
- Are responsibilities and dependencies clearer?
- Can failures be diagnosed?
- Does the abstraction use domain language?
- Can new implementations be added without editing unrelated code?
Useful engineering signals:
- Change lead time.
- Defect rate in the affected area.
- Frequency of merge conflicts.
- Number of modules changed per feature.
- Test execution time and reliability.
- Production incident frequency.
- Build and deployment complexity.
- Time needed for a new developer to complete a change.
Metrics require context. A larger number of classes is not automatically bad, and a smaller codebase is not automatically simpler.
Removing a Pattern That No Longer Pays for Itself
Patterns should not become permanent merely because they were once useful.
Simplification steps can include:
- Confirm that the variation or boundary no longer exists.
- Add characterization tests around current behavior.
- Identify the simplest replacement.
- Collapse pass-through layers incrementally.
- Remove unused implementations and configuration.
- Update dependency registration and documentation.
- Measure whether comprehension and delivery improve.
Examples:
- Replace a plug-in framework with direct registration when plug-ins are no longer external.
- Collapse separate CQRS stores after workload requirements change.
- Remove a generic repository while keeping focused domain repositories.
- Merge services that no longer require independent deployment.
A Compact Interview Decision Checklist
When asked whether to use a pattern, structure the answer around:
1. Current problem and evidence
2. Relevant forces and quality requirements
3. Simpler alternatives
4. Benefits purchased by the pattern
5. Complexity and operational consequences
6. Reversibility of the decision
7. Smallest useful implementation
8. Validation through tests, metrics, and future changes
This approach demonstrates judgment rather than pattern memorization.