Sitemap

How Multiple Design Patterns Work Together in Clean Architecture

4 min readDec 28, 2024

--

Press enter or click to view image in full size

Overview

In this article, we demonstrate how multiple design patterns can work together in a Clean Architecture-based .NET Web API project. The example focuses on implementing a payment processing system that integrates with multiple third-party payment services, such as PayPal and Credit Card providers, using robust design principles.

We’ll cover the following patterns:

  1. Strategy Pattern
  2. Decorator Pattern
  3. Factory Pattern
  4. Mediator Pattern
  5. Singleton Pattern
  6. Observer Pattern
  7. Repository Pattern

Design Patterns in Action

1. Strategy Pattern

Purpose:
The strategy pattern is used to encapsulate multiple payment processing methods into separate classes. This allows dynamic selection of the appropriate payment method at runtime.

Usage in Our Example:
Each payment method (e.g., PayPal, Credit Card) has its own implementation of IPaymentProcessor. The ProcessPayment method executes the appropriate logic without modifying the core business logic.

// IPaymentProcessor.cs
public interface IPaymentProcessor
{
Task<PaymentResult> ProcessPayment(Payment payment);
}

// PayPalPaymentProcessor.cs
public class PayPalPaymentProcessor : IPaymentProcessor
{
public async Task<PaymentResult> ProcessPayment(Payment payment)
{
return new PaymentResult { IsSuccessful = true, Message = "Payment processed via PayPal" };
}
}

// CreditCardPaymentProcessor.cs
public class CreditCardPaymentProcessor : IPaymentProcessor
{
public async Task<PaymentResult> ProcessPayment(Payment payment)
{
return new PaymentResult { IsSuccessful = true, Message = "Payment processed via Credit Card" };
}
}

2. Decorator Pattern

Purpose:
The decorator pattern dynamically adds new functionality to objects without modifying their structure. In this example, we use it to add logging to payment processors.

Usage in Our Example:
The PaymentProcessorWithLogging class wraps an existing payment processor and logs messages during the payment process.

public class PaymentProcessorWithLogging : IPaymentProcessor
{
private readonly IPaymentProcessor _innerProcessor;
private readonly ILogger _logger;

public PaymentProcessorWithLogging(IPaymentProcessor innerProcessor, ILogger logger)
{
_innerProcessor = innerProcessor;
_logger = logger;
}

public async Task<PaymentResult> ProcessPayment(Payment payment)
{
_logger.LogInformation("Starting payment processing...");
var result = await _innerProcessor.ProcessPayment(payment);
_logger.LogInformation($"Payment Result: {result.Message}");
return result;
}
}

3. Factory Pattern

Purpose:
The factory pattern is used to centralize object creation. This eliminates the need for consumers to understand the instantiation details of each object.

Usage in Our Example:
The Startup class uses dependency injection to register factories that create payment processors based on the payment method.

services.AddTransient<IPaymentProcessor, PayPalPaymentProcessor>();
services.AddTransient<IPaymentProcessor, CreditCardPaymentProcessor>();
services.AddTransient<Func<string, IPaymentProcessor>>(serviceProvider => key =>
{
return key switch
{
"PayPal" => serviceProvider.GetService<PayPalPaymentProcessor>(),
"CreditCard" => serviceProvider.GetService<CreditCardPaymentProcessor>(),
_ => throw new ArgumentException("Invalid payment method")
};
});

4. Mediator Pattern

Purpose:
To decouple requests from their handlers. The mediator pattern enables centralized management of communication between objects.

Usage in Our Example:
A ProcessPaymentCommand is handled by the ProcessPaymentCommandHandler, which selects the appropriate payment processor using the factory.

public class ProcessPaymentCommand : IRequest<PaymentResult>
{
public Payment Payment { get; set; }
}

public class ProcessPaymentCommandHandler : IRequestHandler<ProcessPaymentCommand, PaymentResult>
{
private readonly Func<string, IPaymentProcessor> _paymentProcessorFactory;

public ProcessPaymentCommandHandler(Func<string, IPaymentProcessor> paymentProcessorFactory)
{
_paymentProcessorFactory = paymentProcessorFactory;
}

public async Task<PaymentResult> Handle(ProcessPaymentCommand request, CancellationToken cancellationToken)
{
var processor = _paymentProcessorFactory(request.Payment.Method);
return await processor.ProcessPayment(request.Payment);
}
}

5. Singleton Pattern

Purpose:
To ensure a class has only one instance and provide a global access point to it. Here, it’s used for logging to ensure consistent, thread-safe logging.

Usage in Our Example:
The logger is registered as a singleton in Startup.

services.AddSingleton<ILogger, ConsoleLogger>();

6. Observer Pattern

Purpose:
To allow an object to notify multiple observers when its state changes. This is useful for sending email notifications after a payment is processed.

Usage in Our Example:
The EmailServiceWithRetry observes payment events and retries sending emails on failure.

public class EmailServiceWithRetry : IEmailService
{
private readonly IEmailService _innerService;

public EmailServiceWithRetry(IEmailService innerService)
{
_innerService = innerService;
}

public async Task SendEmail(string to, string subject, string body)
{
try
{
await _innerService.SendEmail(to, subject, body);
}
catch
{
await _innerService.SendEmail(to, subject, body); // Retry logic
}
}
}

7. Repository Pattern

Purpose:
To abstract data access logic from the business logic. This ensures adherence to the Single Responsibility Principle.

Usage in Our Example:
The repository handles interactions with the database (PostgreSQL).

public interface IPaymentRepository
{
Task AddPaymentAsync(Payment payment);
Task<IEnumerable<Payment>> GetPaymentsAsync();
}

public class PaymentRepository : IPaymentRepository
{
private readonly ApplicationDbContext _context;

public PaymentRepository(ApplicationDbContext context)
{
_context = context;
}

public async Task AddPaymentAsync(Payment payment)
{
await _context.Payments.AddAsync(payment);
await _context.SaveChangesAsync();
}

public async Task<IEnumerable<Payment>> GetPaymentsAsync()
{
return await _context.Payments.ToListAsync();
}
}

Benefits of this Implementation

  • Scalability: Each design pattern contributes to a modular and extensible design.
  • Testability: Abstractions like repositories and strategies make unit testing straightforward.
  • Maintainability: Patterns like mediator and decorator promote clean, single-responsibility code.
  • Flexibility: Adding new payment processors or features (like email notifications) requires minimal changes.

--

--

Naveen Fernando
Naveen Fernando

Written by Naveen Fernando

25 years old | Senior Software Engineer | Currently live in Sri-Lanka | Interested In Kafka, JavaScript, C#, Java, ReactJs, AWS, Stripe.