How Multiple Design Patterns Work Together in Clean Architecture
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:
- Strategy Pattern
- Decorator Pattern
- Factory Pattern
- Mediator Pattern
- Singleton Pattern
- Observer Pattern
- 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.
