← Back to list

Understanding Design Patterns in .NET

Design patterns are established, reusable solutions to recurring software design challenges. They are not frameworks or libraries, but…

Benedict Odoh · 2026-02-21 15:51 · 1 claps · 8.9 min read
#design-patterns #factory-pattern #repository-pattern #facade-pattern #command-pattern
Open on Medium ↗
Wiki topics: 🥊 · Combat Sports

Understanding Design Patterns in .NET

Design patterns are established, reusable solutions to recurring software design challenges. They are not frameworks or libraries, but rather they are structured approaches to organising code so that systems remain flexible, maintainable, and scalable over time. In the .NET ecosystem, especially when building ASP.NET Core Web APIs or MVC applications, design patterns help enforce clean architecture principles such as separation of concerns, dependency inversion, and loose coupling.

In enterprise .NET systems, complexity grows quickly. Patterns help manage that complexity. They make your code more testable, easier to extend, and more aligned with SOLID principles. Instead of writing tightly coupled controllers that directly access the database or business logic, patterns encourage structured layering.

In this article, we will break down popular design patterns used, such as the Factory, Repository, Facade, and Command Patterns.

Factory Pattern

The Factory Pattern is a creational design pattern that centralizes and encapsulates object creation logic. Instead of instantiating classes throughout your codebase, you delegate the responsibility of creating objects to a dedicated factory class or method. This abstraction allows you to request objects without needing to know the exact class that will be instantiated.

Think of it as a “manufacturing plant” for your objects: you specify what type of product you want, and the factory delivers it, handling all the construction details behind the scenes.

Instead of:

var payment = new BankTransferPayment();

You use:

var payment = PaymentFactory.Create("BankTransfer");

The Factory Pattern involves three main participants. The Product defines the interface or abstract class that specifies what all concrete implementations must provide. The Concrete Product is the actual implementation of that interface, representing the specific object created. The Factory is responsible for instantiating the correct product based on given input or context, ensuring that client code doesn’t need to know the details of object creation. This makes systems more flexible, easier to maintain, and consistent in how objects are created. Additionally, it improves testability by allowing mock objects to be substituted more easily, ensuring that object creation across the application remains uniform and reliable. Together, these roles centralize and encapsulate construction logic, making the system easier to extend and maintain.

Best Use Cases in .NET

  • Creating different services based on runtime conditions
  • Creating payment providers (e.g., Interswitch, Paystack)
  • Selecting bank account types
  • When constructor logic becomes complex
  • When working with dependency injection

Pros and Cons

The Factory Pattern reduces conditional logic in controllers and supports the Open/Closed Principle. It integrates well with dependency injection. However, it can introduce additional abstraction layers. For simple object creation scenarios, it may be overengineering.

Code Snippet

// Product Interface
public interface IPaymentProcessor
{
    void ProcessPayment(decimal amount);
}

// Concrete Products
public class BankTransferProcessor : IPaymentProcessor
{
    public void ProcessPayment(decimal amount)
    {
        Console.WriteLine($"Processing bank transfer of {amount}");
    }
}

public class CardPaymentProcessor : IPaymentProcessor
{
    public void ProcessPayment(decimal amount)
    {
        Console.WriteLine($"Processing card payment of {amount}");
    }
}

// Factory
public static class PaymentFactory
{
    public static IPaymentProcessor Create(string type)
    {
        if (type == "BankTransfer")
        {
            return new BankTransferProcessor();
        }
        else if (type == "Card")
        {
            return new CardPaymentProcessor();
        }
        else
        {
            throw new ArgumentException("Invalid payment type");
        }
    }
}

// Controller
[ApiController]
[Route("api/[controller]")]
public class PaymentsController : ControllerBase
{
    private readonly ILogger<PaymentsController> _logger;

    public PaymentsController(ILogger<PaymentsController> logger)
    {
        _logger = logger;
    }

    [HttpPost]
    public IActionResult ProcessPayment([FromBody] string type, [FromBody] decimal amount)
    {
        _logger.LogInformation("Received payment request. Type: {Type}, Amount: {Amount}", type, amount);

        try
        {
            // Using the factory to create the correct processor
            var processor = PaymentFactory.Create(type);

            _logger.LogInformation("Created processor of type {ProcessorType}", processor.GetType().Name);

            // Executing the payment
            processor.ProcessPayment(amount);

            _logger.LogInformation("Successfully processed payment of {Amount} via {Type}", amount, type);

            return Ok("Payment of was successful.");
        }
        catch (ArgumentException ex)
        {
            _logger.LogWarning(ex, "Invalid payment type provided: {Type}", type);
            return BadRequest(ex.Message);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Unexpected error occurred while processing payment");
            return StatusCode(500, "An unexpected error occurred. Please try again later.");
        }
    }

Repository Pattern

The Repository Pattern is a structural design pattern that centralizes and abstracts data access logic away from business logic. Instead of embedding EF Core queries (or other data access code) directly inside controllers or services, you delegate this responsibility to a dedicated repository class. This abstraction provides a clean interface for retrieving and persisting data, while shielding the rest of the application from the details of the underlying database or ORM.

Think of it as a data access gateway: your application asks the repository for information or requests changes, and the repository handles all the query and persistence details behind the scenes. This separation makes your codebase easier to maintain, test, and evolve as data sources or frameworks change.

Instead of:

_context.Accounts.Where(a => a.Id == id);

You use:

_accountRepository.GetById(id);

The Repository Pattern involves four main participants. The Entity represents the domain model, such as an Account, and defines the core business data. The Repository Interface acts as a contract, specifying the operations available for interacting with entities without exposing implementation details. The Concrete Repository provides the actual implementation of that contract, often using EF Core, Dapper, or another data access technology. Finally, the Data Source is the underlying storage system, such as SQL Server, that persists and retrieves the data. Together, these roles decouple business logic from data access, making the application more maintainable, testable, and flexible; because the repository exposes a consistent interface, you can swap out data sources or ORMs (for example, EF Core → Dapper → ADO.NET) without rewriting your business logic.

Best Use Cases in .NET

  • ASP.NET Core Web APIs
  • Clean Architecture projects
  • Systems using EF Core, ADO .NET, or Dapper
  • Complex query encapsulation
  • Large enterprise apps (banking, audit systems)

Pros and Cons

The Repository Pattern encourages clean architecture, works seamlessly with EF Core, and makes unit testing easier by allowing repositories to be mocked. However, since EF Core already provides repository‑like and Unit of Work functionality, adding another repository layer can sometimes introduce unnecessary abstraction, especially in smaller applications where direct EF Core usage is sufficient.

Code Snippet

// Entity
public class Account
{
    public int Id { get; set; }
    public string AccountNumber { get; set; }
    public decimal Balance { get; set; }
}

// Repository Interface
public class Account
{
    public int Id { get; set; }
    public string AccountNumber { get; set; }
    public decimal Balance { get; set; }
}

// EF Core Implementation
public class AccountRepository : IAccountRepository
{
    private readonly BankingDbContext _context;

    public AccountRepository(BankingDbContext context)
    {
        _context = context;
    }

    public async Task<Account> GetByAccountNumberAsync(string accountNumber)
    {
        return await _context.Accounts
            .FirstOrDefaultAsync(a => a.AccountNumber == accountNumber);
    }

    public async Task UpdateAsync(Account account)
    {
        _context.Accounts.Update(account);
        await _context.SaveChangesAsync();
    }
}

// Dapper Implementation
public class AccountRepository : IAccountRepository
{
    private readonly BankingDbContext _context;

    public AccountRepository(BankingDbContext context)
    {
        _context = context;
    }

    public async Task<Account> GetByAccountNumberAsync(string accountNumber)
    {
        return await _context.Accounts
            .FirstOrDefaultAsync(a => a.AccountNumber == accountNumber);
    }

    public async Task UpdateAsync(Account account)
    {
        _context.Accounts.Update(account);
        await _context.SaveChangesAsync();
    }
}

// Controller
[ApiController]
[Route("api/[controller]")]
public class AccountsController : ControllerBase
{
    private readonly IAccountRepository _accountRepository;
    private readonly ILogger<AccountsController> _logger;

    public AccountsController(IAccountRepository accountRepository, ILogger<AccountsController> logger)
    {
        _accountRepository = accountRepository;
        _logger = logger;
    }

    // GET: api/accounts/{accountNumber}
    [HttpGet("{accountNumber}")]
    public async Task<IActionResult> GetAccount(string accountNumber)
    {
        _logger.LogInformation("Fetching account with AccountNumber: {AccountNumber}", accountNumber);

        var account = await _accountRepository.GetByAccountNumberAsync(accountNumber);

        if (account == null)
        {
            _logger.LogWarning("Account not found: {AccountNumber}", accountNumber);
            return NotFound("Account not found.");
        }

        _logger.LogInformation("Account retrieved successfully: {AccountNumber}, Balance: {Balance}", account.AccountNumber, account.Balance);
        return Ok(account);
    }

    // PUT: api/accounts/{accountNumber}/balance
    [HttpPut("{accountNumber}/balance")]
    public async Task<IActionResult> UpdateBalance(string accountNumber, [FromBody] decimal newBalance)
    {
        _logger.LogInformation("Updating balance for AccountNumber: {AccountNumber} to {NewBalance}", accountNumber, newBalance);

        var account = await _accountRepository.GetByAccountNumberAsync(accountNumber);

        if (account == null)
        {
            _logger.LogWarning("Account not found for update: {AccountNumber}", accountNumber);
            return NotFound("Account not found.");
        }

        account.Balance = newBalance;
        await _accountRepository.UpdateAsync(account);

        _logger.LogInformation("Balance updated successfully for AccountNumber: {AccountNumber}", accountNumber);
        return Ok("Account balance updated successfully.");
    }
}

Facade Pattern

The Facade Pattern is a structural design pattern that simplifies interactions with complex subsystems by providing a single, unified interface. Instead of requiring clients to directly coordinate with multiple services, classes, or APIs, the facade acts as a central entry point that delegates requests to the appropriate components behind the scenes.

Think of it as a front desk in a hotel: guests don’t need to know which department handles reservations, housekeeping, or billing — they simply interact with the front desk, and the facade takes care of routing the request to the right place. This makes the system easier to use, reduces coupling, and improves maintainability.

Instead of:

accountService.ValidateAccount();
fraudService.CheckFraud();
limitService.ValidateLimit();
ledgerService.Debit();
notificationService.SendSms();

You use:

transferFacade.ProcessTransfer(request);

The Facade Pattern has two primary participants. The Facade provides a simplified interface that clients can use to interact with the system, shielding them from the complexity of multiple underlying components. The Subsystems are the internal services or classes that perform the actual work; they remain fully functional but are accessed indirectly through the facade. This arrangement reduces coupling, improves readability, and makes the system easier to use by exposing only what is necessary while hiding intricate details.

Best Use Cases in .NET

  • Microservice integrations
  • Payment processing workflows
  • Complex business operations
  • External API wrappers

Pros and Cons

The Facade Pattern reduces complexity for consumers by centralizing workflow logic and improving readability, making systems easier to use. However, if overloaded, a facade risks becoming a “God class” and may hide too much of the underlying logic, which can limit flexibility and transparency

Code Snippet

//Subsystems
public class FraudService
{
    public bool CheckFraud(string accountNumber) => true;
}

public class NotificationService
{
    public void SendNotification(string message)
    {
        Console.WriteLine("Notification sent: " + message);
    }
}

// Facade
public class TransferFacade
{
    private readonly FraudService _fraudService;
    private readonly NotificationService _notificationService;

    public TransferFacade()
    {
        _fraudService = new FraudService();
        _notificationService = new NotificationService();
    }

    public void ProcessTransfer(string accountNumber)
    {
        if (!_fraudService.CheckFraud(accountNumber))
            throw new Exception("Fraud detected");

        _notificationService.SendNotification("Transfer successful");
    }
}

// Controller
[ApiController]
[Route("api/[controller]")]
public class TransfersController : ControllerBase
{
    private readonly ILogger<TransfersController> _logger;
    private readonly TransferFacade _transferFacade;

    public TransfersController(ILogger<TransfersController> logger)
    {
        _logger = logger;
        _transferFacade = new TransferFacade(); 
    }

    // POST: api/transfers/{accountNumber}
    [HttpPost("{accountNumber}")]
    public IActionResult ProcessTransfer(string accountNumber)
    {
        _logger.LogInformation("Received transfer request for AccountNumber: {AccountNumber}", accountNumber);

        try
        {
            _transferFacade.ProcessTransfer(accountNumber);

            _logger.LogInformation("Transfer processed successfully for AccountNumber: {AccountNumber}", accountNumber);
            return Ok("Transfer completed successfully.");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error occurred while processing transfer for AccountNumber: {AccountNumber}", accountNumber);
            return StatusCode(500, "An error occurred while processing the transfer.");
        }
    }
}

Command Pattern

The Command Pattern is a behavioral design pattern that encapsulates a request as an object, allowing you to parameterize and manage actions in a flexible way. Instead of invoking methods directly, commands wrap the request and its associated data into a dedicated object, which can then be logged, queued, scheduled, or retried as needed.

Think of it as a remote control for actions: each button represents a command object that knows how to execute a specific operation. This abstraction makes it possible to implement advanced features such as undo/redo functionality, task scheduling, and reliable retry logic, all while keeping the client code decoupled from the actual execution details.

Instead of:

accountService.Debit(account, amount);

You use:

var command = new DebitCommand(account, amount);
command.Execute();

The Command Pattern involves four key participants, each with a distinct responsibility. The Command encapsulates the action itself, packaging the request and any necessary data into an object. The Receiver is the component that knows how to perform the actual work when the command is executed. The Invoker triggers the command, acting as the middleman that decides when and which command should run. Finally, the Client is responsible for creating and configuring the command objects, linking them to the appropriate receivers. Together, these roles decouple the request from its execution, enabling flexible features like undo/redo, logging, and scheduling.

Best Use Cases in .NET

  • Command Query Responsibility Segregation (CQRS) systems
  • Background job systems
  • Undo/Redo features
  • Message queue processing
  • MediatR usage

Pros and Cons

The Command Pattern works well with CQRS, enables a clean separation of concerns, simplifies auditing, and is especially useful for background processing. However, it also introduces more classes into the codebase and can feel heavy for smaller applications where such abstraction may not be necessary.

Code Snippet

// Command Interface
public interface ICommand
{
    void Execute();
    void Undo();
}

// Receiver
public class AccountService
{
    public void Debit(Account account, decimal amount)
    {
        account.Balance -= amount;
    }

    public void Credit(Account account, decimal amount)
    {
        account.Balance += amount;
    }
}

// Concrete Command
public class DebitCommand : ICommand
{
    private readonly AccountService _service;
    private readonly Account _account;
    private readonly decimal _amount;

    public DebitCommand(AccountService service, Account account, decimal amount)
    {
        _service = service;
        _account = account;
        _amount = amount;
    }

    public void Execute()
    {
        _service.Debit(_account, _amount);
    }

    public void Undo()
    {
        _service.Credit(_account, _amount);
    }
}

// Controller
[ApiController]
[Route("api/[controller]")]
public class CommandsController : ControllerBase
{
    private readonly ILogger<CommandsController> _logger;
    private readonly AccountService _accountService;

    public CommandsController(ILogger<CommandsController> logger)
    {
        _logger = logger;
        _accountService = new AccountService(); // Receiver
    }

    // POST: api/commands/debit
    [HttpPost("debit")]
    public IActionResult ExecuteDebit([FromBody] DebitRequest request)
    {
        _logger.LogInformation("Received debit request for AccountNumber: {AccountNumber}, Amount: {Amount}", request.Account.AccountNumber, request.Amount);

        try
        {
            var command = new DebitCommand(_accountService, request.Account, request.Amount);
            command.Execute();

            _logger.LogInformation("Debit executed successfully. AccountNumber: {AccountNumber}, New Balance: {Balance}", request.Account.AccountNumber, request.Account.Balance);
            return Ok("Debit executed successfully.");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error occurred while executing debit for AccountNumber: {AccountNumber}", request.Account.AccountNumber);
            return StatusCode(500, "An error occurred while processing the debit.");
        }
    }

    // POST: api/commands/debit/undo
    [HttpPost("debit/undo")]
    public IActionResult UndoDebit([FromBody] DebitRequest request)
    {
        _logger.LogInformation("Received undo request for AccountNumber: {AccountNumber}, Amount: {Amount}", request.Account.AccountNumber, request.Amount);

        try
        {
            var command = new DebitCommand(_accountService, request.Account, request.Amount);
            command.Undo();

            _logger.LogInformation("Debit undone successfully. AccountNumber: {AccountNumber}, New Balance: {Balance}", request.Account.AccountNumber, request.Account.Balance);
            return Ok("Debit undone successfully.");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error occurred while undoing debit for AccountNumber: {AccountNumber}", request.Account.AccountNumber);
            return StatusCode(500, "An error occurred while undoing the debit.");
        }
    }
}

// DTO for request payload
public class DebitRequest
{
    public Account Account { get; set; }
    public decimal Amount { get; set; }
}

Conclusion

Design patterns are structured solutions that help manage complexity, improve maintainability, and promote clean architecture. The Factory Pattern centralizes object creation to reduce tight coupling, the Repository Pattern separates data access from business logic to improve testability and flexibility, the Facade Pattern simplifies complex workflows by providing a unified interface to multiple subsystems, and the Command Pattern encapsulates actions as objects to support features like logging, undo/redo, and scalable request handling. When applied thoughtfully, these patterns make .NET applications more organized, extensible, and easier to maintain over time.


메타데이터
post_id
e6bfeeecdc0e
slug
understanding-design-patterns-in-net-e6bfeeecdc0e
url
https://medium.com/@benedictodoh/understanding-design-patterns-in-net-e6bfeeecdc0e
canonical_url
https://medium.com/@benedictodoh/understanding-design-patterns-in-net-e6bfeeecdc0e
author_url
https://medium.com/@benedictodoh
status
ok
fetched_at
2026-07-19 18:06:16