DEV Community

Maria
Maria

Posted on

Advanced C# Design Patterns: Beyond the Basics

Advanced C# Design Patterns: Beyond the Basics

Design patterns in software development are like well-trodden paths in a dense forest—they guide us through complex problems using proven solutions. For experienced C# developers, mastering foundational patterns like Singleton or Factory is just the beginning. To truly elevate your craft, you need to dive into advanced design patterns that address more nuanced and sophisticated challenges.

In this blog post, we’ll explore three advanced design patterns: the Repository Pattern, Unit of Work, and Command Query Responsibility Segregation (CQRS). You'll learn not just how to implement them in C#, but also why they matter and how they can improve the architecture of your applications.


Why Advanced Design Patterns Matter

As applications grow in complexity, so does the need for maintainable, scalable, and testable code. Advanced design patterns help us achieve this by:

  • Abstracting complexity: They hide implementation details and let you work with higher-level abstractions.
  • Improving testability: They decouple components, making it easier to write unit tests.
  • Enhancing maintainability: They reduce the technical debt that comes with tightly coupled code.

By the end of this post, you'll have a deep understanding of these patterns and how to implement them effectively in your applications.


The Repository Pattern

What Is the Repository Pattern?

Imagine you're a librarian. Instead of letting people rummage through shelves, you act as an intermediary. People tell you what book they need, and you fetch it for them. The Repository Pattern works in a similar way: it acts as a mediator between the application's business logic and the data access layer.

In C#, a repository abstracts the data access logic, providing a clean API for querying and persisting objects without exposing the underlying database or ORM (e.g., Entity Framework) details.

Why Use It?

  • Encapsulation: Hides the details of data access.
  • Testability: Makes it easier to mock the data layer in tests.
  • Consistency: Provides a standardized way to interact with data.

Implementation Example

Let’s implement a generic repository for an Order entity.

// Define the entity
public class Order
{
    public int Id { get; set; }
    public string CustomerName { get; set; }
    public DateTime OrderDate { get; set; }
}

// Define the IRepository interface
public interface IRepository<T> where T : class
{
    T GetById(int id);
    IEnumerable<T> GetAll();
    void Add(T entity);
    void Update(T entity);
    void Delete(int id);
}

// Implement the repository
public class Repository<T> : IRepository<T> where T : class
{
    private readonly DbContext _context;
    private readonly DbSet<T> _dbSet;

    public Repository(DbContext context)
    {
        _context = context;
        _dbSet = context.Set<T>();
    }

    public T GetById(int id) => _dbSet.Find(id);

    public IEnumerable<T> GetAll() => _dbSet.ToList();

    public void Add(T entity) => _dbSet.Add(entity);

    public void Update(T entity) => _context.Entry(entity).State = EntityState.Modified;

    public void Delete(int id)
    {
        var entity = _dbSet.Find(id);
        if (entity != null)
        {
            _dbSet.Remove(entity);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Real-World Use Case

This pattern is particularly useful when working with ORMs like Entity Framework. It abstracts the data access code, making your application more modular and easier to test.


The Unit of Work Pattern

What Is the Unit of Work Pattern?

Think of a Unit of Work (UoW) as a shopping cart. Instead of purchasing items one by one (and paying transaction fees each time), you add them to the cart and pay for them all at once. Similarly, the Unit of Work pattern batches multiple database operations into a single transaction.

Why Use It?

  • Transaction Management: Ensures atomicity of multiple operations.
  • Performance: Reduces the number of database calls.
  • Consistency: Maintains data integrity across multiple updates.

Implementation Example

Here’s how you can implement a Unit of Work in conjunction with the Repository Pattern.

// Define the IUnitOfWork interface
public interface IUnitOfWork : IDisposable
{
    IRepository<Order> Orders { get; }
    IRepository<Customer> Customers { get; }
    void SaveChanges();
}

// Implement the UnitOfWork class
public class UnitOfWork : IUnitOfWork
{
    private readonly DbContext _context;
    private IRepository<Order> _orders;
    private IRepository<Customer> _customers;

    public UnitOfWork(DbContext context)
    {
        _context = context;
    }

    public IRepository<Order> Orders => _orders ??= new Repository<Order>(_context);
    public IRepository<Customer> Customers => _customers ??= new Repository<Customer>(_context);

    public void SaveChanges() => _context.SaveChanges();

    public void Dispose() => _context.Dispose();
}
Enter fullscreen mode Exit fullscreen mode

Real-World Use Case

The Unit of Work pattern shines when you need to coordinate multiple repositories in a single business transaction, e.g., placing an order while updating stock levels.


Command Query Responsibility Segregation (CQRS)

What Is CQRS?

CQRS stands for Command Query Responsibility Segregation. It’s a pattern that separates the read (query) and write (command) operations of a system into distinct models.

Think of it like a restaurant: the chef (write model) focuses on cooking, while the waiter (read model) focuses on taking orders and delivering food. They have distinct responsibilities, which leads to specialization.

Why Use It?

  • Scalability: Separating read and write operations allows independent scaling.
  • Performance: Optimizes each model for its specific purpose.
  • Flexibility: Allows different data stores or schemas for reads and writes.

Implementation Example

Here’s a basic example of CQRS in C#.

Command Model (Write)

public class CreateOrderCommand
{
    public string CustomerName { get; set; }
    public DateTime OrderDate { get; set; }
}

public class OrderCommandHandler
{
    private readonly IRepository<Order> _orderRepository;

    public OrderCommandHandler(IRepository<Order> orderRepository)
    {
        _orderRepository = orderRepository;
    }

    public void Handle(CreateOrderCommand command)
    {
        var order = new Order
        {
            CustomerName = command.CustomerName,
            OrderDate = command.OrderDate
        };
        _orderRepository.Add(order);
    }
}
Enter fullscreen mode Exit fullscreen mode

Query Model (Read)

public class OrderDto
{
    public int Id { get; set; }
    public string CustomerName { get; set; }
    public DateTime OrderDate { get; set; }
}

public class OrderQueryService
{
    private readonly DbContext _context;

    public OrderQueryService(DbContext context)
    {
        _context = context;
    }

    public IEnumerable<OrderDto> GetOrders()
    {
        return _context.Set<Order>()
            .Select(o => new OrderDto
            {
                Id = o.Id,
                CustomerName = o.CustomerName,
                OrderDate = o.OrderDate
            })
            .ToList();
    }
}
Enter fullscreen mode Exit fullscreen mode

Real-World Use Case

CQRS is ideal for complex systems where read and write workloads differ significantly, such as e-commerce platforms or event-sourced systems.


Common Pitfalls and How to Avoid Them

  1. Overengineering: Don’t use these patterns for simple applications. They add complexity and are best suited to large, scalable projects.
  2. Tight Coupling: Ensure proper abstraction layers to avoid coupling repositories or Unit of Work to specific entities or implementations.
  3. Inefficient Queries: In CQRS, poorly optimized queries can negate the performance benefits. Use tools like LINQ efficiently and consider caching where appropriate.

Key Takeaways

  • The Repository Pattern abstracts data access, making your code modular and testable.
  • The Unit of Work Pattern ensures transactional consistency across multiple operations.
  • CQRS separates read and write models, improving scalability and performance.

Next Steps

  • Practice implementing these patterns in a sample project.
  • Explore related topics like Dependency Injection and Domain-Driven Design.
  • Check out advanced frameworks like MediatR for CQRS.

By mastering these patterns, you'll not only write better C# code but also architect robust and maintainable systems. Happy coding! 🚀

Top comments (0)