Introduction
If you've been developing .NET applications for any length of time, you've probably encountered the same question over and over: "Which architecture pattern should I use for this project?"
The .NET ecosystem offers a dizzying array of architectural patterns Repository, Unit of Work, CQRS, Clean Architecture, Onion Architecture, Vertical Slice, and more. Each has its advocates, each promises to solve your problems, and each can absolutely make things worse if applied incorrectly.
In this guide, I'll walk through the most important architecture patterns in C# and .NET, explain how each one works, and most importantly give you practical guidance on when to use each pattern based on your project's actual needs.
Part 1: Data Access Patterns
Let's start with patterns that govern how your application interacts with data. These are the foundation of most .NET applications.
1. Repository Pattern
What It Is
The Repository Pattern creates an abstraction layer between your business logic and data access logic. Instead of scattering database queries throughout your application, you centralize them in repository classes that expose methods like GetById, GetAll, Add, Update, and Delete.
Think of repositories as a collection-like interface to your data. Your business logic asks the repository for entities without knowing whether they come from SQL Server, Cosmos DB, or an API. The repository handles all the data retrieval details.
Key Benefits
- Centralizes data access logic in one place
- Makes your code more testable through interfaces
- Easier to swap data sources without changing business logic
- Enforces consistent data access patterns across teams
When to Use It
- You're using Entity Framework Core and want to abstract away EF-specific code from your business layer
- You need to switch between different data sources (SQL, NoSQL, APIs) or anticipate doing so
- You want highly testable code without hitting the actual database in unit tests
- Your team needs consistent, reusable data access patterns across the application
- Complex querying logic that you want to encapsulate and reuse
When NOT to Use It
- Simple CRUD applications where EF Core's DbContext already provides sufficient abstraction
- You're using Dapper or raw SQL queries (Repository adds unnecessary complexity and overhead)
- Your application has complex, custom queries that don't fit the generic repository pattern well
- You're building a prototype or MVP where speed matters more than perfect architecture
2. Unit of Work Pattern
What It Is
Unit of Work maintains a list of objects affected by a business transaction and coordinates writing changes to the database. It ensures that all repository operations within a single business transaction share the same database context and get committed or rolled back together.
Imagine you're processing an order that requires updating inventory, creating an order record, and charging a payment. The Unit of Work ensures all these changes happen together if any step fails, everything rolls back. It's the transactional glue that binds multiple repository operations.
Key Benefits
- Ensures transactional consistency across multiple operations
- Coordinates commits across multiple repositories
- Reduces database round-trips by batching changes
- Provides a clear transaction boundary in your code
When to Use It
- You have operations that span multiple repositories and need guaranteed transactional consistency
- You're using the Repository Pattern and need coordinated saves across different entity types
- Complex business operations that modify multiple entities and must succeed or fail as a unit
- You want explicit control over when database changes are persisted
When NOT to Use It
- EF Core's DbContext already implements Unit of Work adding another layer is redundant
- Simple applications with single-entity operations that don't need cross-repository coordination
- Microservices architecture where each service has its own database (distributed transactions are different)
- You're not using the Repository Pattern (Unit of Work is typically paired with repositories)
3. Specification Pattern
What It Is
The Specification Pattern encapsulates business rules and query logic into reusable specification objects. Instead of writing the same filter conditions repeatedly throughout your codebase, you define them once as specifications and compose them as needed.
For example, instead of writing "where product.IsActive && !product.IsDeleted && product.Price > 0" in multiple places, you create an "ActiveProductsSpecification" that encapsulates this logic. You can then combine specifications (active AND in price range AND in stock) to build complex queries.
Key Benefits
- Makes business rules explicit and reusable
- Easy to test business rules in isolation
- Supports composing complex queries from simple building blocks
- Reduces code duplication across queries
When to Use It
- Complex filtering logic that gets reused across different queries and contexts
- Business rules that need to be validated, tested, and maintained independently
- Dynamic query building based on user input or varying business conditions
- Domain-driven design projects where specifications are part of the domain model
When NOT to Use It
- Simple, straightforward queries that don't get reused or aren't business-critical
- When LINQ queries are already clear, readable, and don't duplicate logic
- Small applications where the added abstraction outweighs the benefits
Part 2: Architectural Patterns
These patterns define the overall structure of your application how layers communicate, where dependencies point, and how your code is organized at the highest level.
4. Layered (N-Tier) Architecture
What It Is
The classic approach to organizing code: horizontal layers where each layer has a specific responsibility. Typically you have a Presentation Layer (UI/Controllers), Business Logic Layer (Services), Data Access Layer (Repositories), and the Database. Each layer depends only on the layer directly below it, creating a top-down dependency flow.
This is the architecture pattern most developers learn first. It's straightforward: controllers call services, services call repositories, repositories talk to the database. Dependencies flow downward like water, and each layer is blissfully unaware of what's above it.
Key Benefits
- Easy to understand and widely recognized
- Clear separation of concerns by technical responsibility
- Natural fit for traditional team structures (frontend, backend, data teams)
- Works well for moderate complexity applications
When to Use It
- Traditional enterprise applications with clear separation of UI, business logic, and data
- Teams familiar with classic MVC or three-tier architecture patterns
- Monolithic applications with moderate complexity that don't require advanced domain modeling
- Internal business applications where speed of development matters more than perfect architecture
When NOT to Use It
- Domain-driven design projects dependencies flow the wrong direction (infrastructure depends on domain)
- Highly complex business domains requiring rich domain models with business logic
- Microservices architecture where you need strong boundaries and independence
- Applications where you frequently need to swap infrastructure components
5. Clean Architecture
What It Is
Clean Architecture inverts the traditional layering philosophy. Instead of having your database and infrastructure at the bottom supporting everything above, your business domain sits at the center, and everything else UI, database, external services depends on it. Infrastructure details are pushed to the outer layers.
Picture concentric circles: the innermost circle is your domain entities and business rules. The next layer out is your application use cases. Then comes the infrastructure layer (database, APIs, frameworks). Finally, the outermost layer is your UI. Dependencies point inward outer layers know about inner layers, but inner layers know nothing about what's outside them.
The Four Layers
- Domain Layer: Entities, value objects, domain events your core business logic with zero dependencies
- Application Layer: Use cases, commands, queries, DTOs orchestrates domain logic
- Infrastructure Layer: Database access, external APIs, file systems implements interfaces defined in domain
- Presentation Layer: Web API, UI, controllers handles user interaction
Key Benefits
- Business logic is completely independent of frameworks and infrastructure
- Easy to test you can test business rules without touching databases or UI
- Highly maintainable for long-lived applications
- Can swap infrastructure components (database, messaging) without touching business logic
When to Use It
- Complex business domains with rich, evolving business logic
- Long-lived applications where business rules change frequently but infrastructure stays stable
- Need to swap infrastructure components (different databases, message queues, cloud providers)
- Team values testability, maintainability, and separation of concerns
- Enterprise applications with multiple teams working on different aspects
When NOT to Use It
- Simple CRUD applications this is massive architectural overkill
- Prototypes or MVPs that need to ship quickly and prove market fit
- Small teams unfamiliar with domain-driven design principles
- Projects with tight deadlines where speed matters more than perfect architecture
6. Onion Architecture
What It Is
Onion Architecture is similar to Clean Architecture but emphasizes the dependency rule even more strictly. Think of it as concentric circles (like an onion) where the domain model is the innermost circle, and all dependencies point inward toward the domain. No inner layer ever references an outer layer.
Key Differences from Clean Architecture
- More explicit emphasis on domain services as a separate layer
- Infrastructure and UI are strictly in the outermost layer with no exceptions
- Application services orchestrate domain logic but never contain business rules
- More prescriptive about what belongs in each layer
When to Use It
Same scenarios as Clean Architecture. The choice between them is mostly philosophical Onion Architecture tends to be more prescriptive and strict about layer organization, while Clean Architecture allows slightly more flexibility. Both achieve the same goal: domain-centric design with proper dependency management.
7. Hexagonal Architecture (Ports and Adapters)
What It Is
Hexagonal Architecture (also called Ports and Adapters) focuses on isolating the application core from external concerns through well-defined interfaces. The hexagon represents your application's business logic, with ports (interfaces) on each side connecting to different adapters (implementations).
Think of your application as a hexagon with multiple sides. Each side has a port an interface that defines how the outside world can interact with your application. Adapters plug into these ports to provide actual implementations. You might have a port for payment processing with adapters for Stripe, PayPal, and Square. Your business logic only knows about the port, not which adapter is plugged in.
Key Concepts
- Ports: Interfaces defined by your application core (e.g., IPaymentGateway, IEmailService)
- Adapters: Concrete implementations that plug into ports (e.g., StripeAdapter, SendGridAdapter)
- Primary Adapters: Drive the application (Web API, Console UI)
- Secondary Adapters: Driven by the application (Database, Email, External APIs)
Key Benefits
- Extremely testable mock any adapter easily
- Swap external services without touching core logic
- Business logic remains technology-agnostic
- Great for applications with many integrations
When to Use It
- Applications with multiple external integrations (payment gateways, shipping providers, notification services)
- Need to frequently swap external services based on configuration or business needs
- Testing is critical and you need to easily mock all external dependencies
- Building a system that might have different frontends (web, mobile, desktop, CLI)
When NOT to Use It
- Simple applications with few external dependencies
- When you're certain about your technology choices and won't need to swap them
8. Vertical Slice Architecture
What It Is
Vertical Slice Architecture is a radical departure from traditional layering. Instead of organizing code by technical layers (controllers, services, repositories), you organize by features or use cases. Each feature is a vertical slice through all layers, containing everything needed for that specific functionality request handling, business logic, data access, and response.
For example, instead of having a Products folder with controllers, a separate Services folder with ProductService, and a Repositories folder with ProductRepository, you'd have a CreateProduct folder containing everything needed to create a product: the command, handler, validator, and even the endpoint definition. Each feature stands alone.
Key Benefits
- High cohesion everything related to a feature is together
- Low coupling features are independent and don't share code
- Easy to understand navigate by feature, not by technical layer
- Teams can own features end-to-end
- Easier to delete unused features just remove the folder
When to Use It
- Feature-rich applications where features are relatively independent
- Teams organized around features or product areas rather than technical roles
- Rapid feature development with minimal cross-feature dependencies
- Microservices where each service has focused, cohesive functionality
- Applications using CQRS where commands and queries are naturally isolated
When NOT to Use It
- Significant shared business logic across many features (leads to code duplication)
- Complex domain logic requiring rich domain models with relationships
- Teams organized by technical specialization (frontend, backend, data)
Part 3: Behavioral Patterns
These patterns govern how objects communicate and how responsibilities are distributed within your application.
9. CQRS (Command Query Responsibility Segregation)
What It Is
CQRS separates read operations (queries) from write operations (commands). Instead of using the same model, methods, and sometimes even database for both reading and writing data, you create distinct models optimized for each purpose.
On the write side (commands), you might have a normalized relational database optimized for transactional integrity. On the read side (queries), you could have denormalized views, read-optimized databases, or even cached projections. The two sides can evolve independently based on their specific performance and complexity needs.
Levels of CQRS
- Simple CQRS: Different models and handlers for commands vs queries, same database
- CQRS with read models: Denormalized read tables optimized for queries
- Full CQRS: Separate databases for reads and writes with eventual consistency
Key Benefits
- Read and write models can be optimized independently
- Scales reads and writes separately
- Simpler query logic no complex joins for reporting
- Clear separation of intent (changing data vs reading data)
When to Use It
- Complex domains where read and write requirements differ significantly
- High-performance applications needing optimized read models for reporting
- Systems with eventual consistency requirements between read and write sides
- Different scaling needs for reads vs. writes (e.g., high read volume, low write volume)
- Combining with Event Sourcing for audit trails and temporal queries
When NOT to Use It
- Simple CRUD applications where the added complexity provides no benefit
- Strong consistency requirements across all operations
- Small applications where a single model works perfectly fine
- Teams not ready to handle eventual consistency complexity
10. Mediator Pattern
What It Is
The Mediator Pattern reduces coupling between components by introducing a mediator object that handles all communication. Instead of your controllers directly calling multiple services, they send requests to a mediator, which routes them to the appropriate handler. In .NET, MediatR is the most popular implementation.
Without Mediator, your controller knows about OrderService, EmailService, InventoryService, PaymentService it's tightly coupled to all of them. With Mediator, your controller only knows about the mediator and sends a single command. The mediator routes it to a handler that knows about all those services. Your controller stays thin and focused solely on HTTP concerns.
Key Benefits
- Controllers/endpoints become thin and focused only on HTTP concerns
- Handlers are single-purpose and easy to test in isolation
- Supports pipeline behaviors for cross-cutting concerns (logging, validation, caching)
- Works perfectly with CQRS pattern
When to Use It
- You want thin controllers/endpoints focused only on HTTP/presentation concerns
- Using CQRS pattern MediatR works perfectly with commands and queries
- Need cross-cutting concerns like logging, validation, caching via pipeline behaviors
- Testing individual handlers in isolation without controller overhead
- Large applications where organizing by request/response pairs makes sense
When NOT to Use It
- Simple applications where direct service injection is clearer and more straightforward
- Team is unfamiliar with the pattern and doesn't have time for the learning curve
- You prefer seeing all dependencies explicitly in constructor parameters
Part 4: Choosing the Right Pattern - A Decision Framework
Now that we've covered the patterns, let's talk about how to actually choose. Here's a practical decision framework based on your project's real characteristics.
| Project Type | Recommended Pattern | Why |
|---|---|---|
| Simple CRUD API | Minimal API + EF Core (No Pattern) | Keep it simple patterns would add overhead without benefit |
| Internal Business App | Layered Architecture + Repository | Familiar pattern, moderate complexity, fast delivery |
| Complex Domain Logic | Clean Architecture + DDD | Rich domain models, evolving business rules, long-term maintainability |
| Microservice | Vertical Slice + CQRS | Feature isolation, independent deployment, minimal coupling |
| High-Performance System | CQRS + Event Sourcing | Optimized read models, independent scaling, audit trails |
| Integration-Heavy App | Hexagonal + Ports/Adapters | Easy to swap external services, highly testable |
| E-commerce Platform | Clean Architecture + CQRS + MediatR | Complex workflows, separate read/write needs, testability |
| Rapid Prototyping/MVP | No Pattern - Ship Fast | Validate market fit first, refactor later if needed |
Common Pattern Combinations That Work Well Together
Patterns aren't mutually exclusive. In fact, some patterns complement each other beautifully. Here are proven combinations used in production systems:
Combination 1: Clean Architecture + CQRS + MediatR
Perfect for: Enterprise applications with complex business domains
Why it works
- Clean Architecture provides the overall structure and dependency flow
- CQRS separates reads and writes at the application layer
- MediatR handles command/query dispatch and cross-cutting concerns
- Domain layer stays pure with rich business logic
Combination 2: Vertical Slice + MediatR + Feature Folders
Perfect for: Modern microservices and feature-focused teams
Why it works
- Each feature is completely self-contained with everything it needs
- MediatR provides consistent structure within each slice
- Minimal cross-feature dependencies allow independent deployment
- Easy to understand and navigate by business capability
Combination 3: Layered Architecture + Repository + Unit of Work
Perfect for: Traditional business applications with established teams
Why it works
- Proven pattern familiar to most .NET developers
- Good separation of concerns without excessive complexity
- Transactional consistency via Unit of Work across repositories
- Testable through repository interfaces
Combination 4: Hexagonal Architecture + Specification Pattern
Perfect for: Applications with complex business rules and multiple integrations
Why it works
- Hexagonal keeps core logic isolated from infrastructure
- Specifications encapsulate business rules that work across different adapters
- Easy to test rules independently of infrastructure
Red Flags: Warning Signs You're Using the Wrong Pattern
Sometimes the pattern isn't the problem it's the wrong pattern for your situation. Watch out for these warning signs:
Too Much Abstraction
- You have 5+ layers of indirection for a simple CRUD app
- Generic repositories with only one implementation that never changes
- More boilerplate and ceremony than actual business logic
- Developers spend more time navigating the architecture than solving problems
Fighting the Framework
- Wrapping EF Core in repositories that just delegate every call to DbContext
- Complex workarounds and hacks to make the pattern fit your use case
- Team constantly confused about where code should go
- Documentation explaining the architecture is longer than the actual code
Premature Optimization
- Implementing CQRS with Event Sourcing for a weekend prototype
- Clean Architecture with full DDD for a simple internal tool
- Microservices architecture when a single API would suffice
- Planning for scale you'll never reach while ignoring features users actually need
Copy-Paste Architecture
- Using a pattern because you saw it in a blog post or conference talk
- Implementing a pattern without understanding why it exists
- "This is how we always do it" without questioning if it fits this project
Practical Advice: Start Simple, Evolve Gradually
Here's the approach I recommend for most projects, learned from years of both under-engineering and over-engineering applications:
Phase 1: Start with the Simplest Thing That Could Work
- Use Minimal APIs or controllers directly with EF Core no abstraction layers
- No repositories unless you have a concrete, immediate reason for them
- Focus on delivering features and understanding the domain
- Let pain points emerge naturally through actual development
Phase 2: Add Patterns When Pain Points Become Clear
- Controllers getting fat with too much logic? Add services or command handlers
- Duplicate query logic everywhere? Consider Repository or Specification
- Complex business rules scattered? Introduce domain services
- Different read and write needs? Evaluate CQRS
Phase 3: Refactor to Full Pattern Only If Genuinely Needed
- Project growing significantly in complexity? Consider Clean Architecture
- Performance bottlenecks in reads vs writes? Full CQRS implementation
- Multiple teams working on same codebase? Look at Vertical Slice
- Growing list of external integrations? Hexagonal Architecture pays off
Key Principle: Let real problems drive architectural decisions, not theoretical ones. Your architecture should solve actual pain points you're experiencing, not hypothetical problems you might face someday.
Final Thoughts: Pattern Selection is Context-Dependent
Architecture patterns are tools in your toolbox, not commandments carved in stone. The "best" pattern is the one that solves your actual problems without creating new ones that are worse.
Don't choose Clean Architecture because it sounds impressive on your resume. Don't use CQRS because you read about it in this blog post (or any blog post). Don't implement microservices because Netflix does it. Choose patterns based on your project's real constraints: team size, domain complexity, timeline pressures, performance requirements, and long-term maintenance needs.
The best architecture is one your team can understand, maintain, and evolve over time. Sometimes that's a sophisticated multi-layered Clean Architecture with CQRS and Event Sourcing. Sometimes it's Minimal APIs with EF Core and some well-organized service classes. Both can be exactly right for their context.
Remember: premature abstraction is just as dangerous as premature optimization. Start simple. Add complexity only when simplicity fails. Refactor toward patterns when you feel the pain of not having them, not because you think you might need them someday.
Your future self the one maintaining this code at 2 AM trying to fix a production bug will thank you for choosing clarity over cleverness.
Quick Reference Cheat Sheet
| Pattern | Best Use Case | Avoid When |
|---|---|---|
| Repository | Multiple data sources, need for testability | Simple CRUD with EF Core |
| Unit of Work | Multi-repository transactions | EF Core already provides it |
| Clean Architecture | Complex domain, long-lived apps | Prototypes, MVPs, simple apps |
| CQRS | Different read/write optimization needs | Simple CRUD, strong consistency |
| Vertical Slice | Feature-focused, independent features | Lots of shared business logic |
| Hexagonal | Many external integrations | Few external dependencies |
| Mediator | Thin controllers, CQRS, cross-cutting concerns | Direct service calls sufficient |
| Layered | Traditional enterprise, familiar teams | DDD projects, microservices |
Remember: The goal is to ship working software that solves real problems, not to build the perfect architecture. Choose patterns that serve your users and your team.

No comments:
Post a Comment