Summary
This article explains the Don't Repeat Yourself (DRY) design principle in C# through practical, real-world examples. You'll learn how to identify and eliminate code duplication, refactor repeated logic into reusable components, and combine DRY with other design principles like KISS and SOLID. Whether you're building web applications, APIs, or enterprise systems, this guide provides actionable techniques to make your C# code more maintainable and less error-prone.
Prerequisites
Basic understanding of C# and .NET Framework or .NET Core
Familiarity with object-oriented programming concepts
Experience with Visual Studio or a similar IDE
Basic knowledge of SOLID principles is helpful but not required
The core idea behind the Don't Repeat Yourself (DRY) principle is straightforward: every piece of knowledge should have a single, unambiguous representation in your system .
When you find yourself copying and pasting code, that's a red flag. Let me walk you through practical scenarios where DRY can transform your C# codebase.
Understanding the DRY Principle
The DRY principle was introduced by Andy Hunt and Dave Thomas in their book "The Pragmatic Programmer." It states that duplication in logic should be eliminated via abstraction, and duplication in process should be eliminated via automation. The principle emphasizes that a piece of logic or business rule should exist in exactly one place in your codebase.
Violating DRY leads to maintenance nightmares. When a business rule changes, you must hunt down every duplicate instance and update them all. Miss one, and you introduce bugs. The more places logic is duplicated, the higher the risk of inconsistency and the greater the effort required for any change.
Why does duplication happen?
Developers often duplicate code under time pressure, when they don't recognize the pattern yet, when copy-paste seems faster than abstraction, or when they fear breaking existing functionality. But the short-term convenience of duplication becomes long-term technical debt.
The DRY mindset means
Extract repeated logic into shared methods or classes
Use constants for repeated values
Create base classes or interfaces for shared behavior
Leverage dependency injection for reusable services
Ensure business rules exist in one authoritative location
Now let's see how to apply DRY in real C# scenarios.
The Email Validation Problem
Imagine you're building a user management system. You need to validate email addresses in multiple places: registration, profile updates, and contact forms.
Here's what many developers write:
public void RegisterUser(string email, string password)
{
if (string.IsNullOrWhiteSpace(email) || !email.Contains("@"))
{
throw new Exception("Invalid email format");
}
// Registration logic...
}
public void UpdateProfile(string email, string phone)
{
if (string.IsNullOrWhiteSpace(email) || !email.Contains("@"))
{
throw new Exception("Invalid email format");
}
// Update logic...
}
public void SendNewsletter(string email, string content)
{
if (string.IsNullOrWhiteSpace(email) || !email.Contains("@"))
{
throw new Exception("Invalid email format");
}
// Send logic...
}
What's the problem? The validation logic appears three times. Tomorrow, if your business requires more robust validation (checking domain, format, etc.), you'll need to update it everywhere.
Solution: Extract Validation Logic
Create a dedicated validator:
public static class EmailValidator
{
private static readonly Regex EmailRegex =
new Regex(@"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$");
public static bool IsValid(string email)
{
return !string.IsNullOrWhiteSpace(email) &&
EmailRegex.IsMatch(email);
}
public static void ValidateOrThrow(string email)
{
if (!IsValid(email))
{
throw new ArgumentException("Invalid email format");
}
}
}
Now your methods become cleaner:
public void RegisterUser(string email, string password)
{
EmailValidator.ValidateOrThrow(email);
// Registration logic...
}
public void UpdateProfile(string email, string phone)
{
EmailValidator.ValidateOrThrow(email);
// Update logic...
}
public void SendNewsletter(string email, string content)
{
EmailValidator.ValidateOrThrow(email);
// Send logic...
}
One change, propagated everywhere!
Logging Repetition Across Your Application
Consider this common pattern in enterprise applications:
public class OrderService
{
public void CreateOrder(Order order)
{
try
{
// Order creation logic
Console.WriteLine($"[INFO] Order {order.Id} created at {DateTime.Now}");
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Failed to create order: {ex.Message} at {DateTime.Now}");
throw;
}
}
}
public class PaymentService
{
public void ProcessPayment(Payment payment)
{
try
{
// Payment logic
Console.WriteLine($"[INFO] Payment {payment.Id} processed at {DateTime.Now}");
}
catch (Exception ex)
{
Console.WriteLine($"[ERROR] Failed to process payment: {ex.Message} at {DateTime.Now}");
throw;
}
}
}
Every service repeats the same logging pattern. What happens when you need to switch from Console to a file, or add structured logging with correlation IDs?
Solution: Centralize Logging Infrastructure
public interface ILogger
{
void LogInfo(string message);
void LogError(string message, Exception ex);
}
public class ConsoleLogger : ILogger
{
public void LogInfo(string message)
{
Console.WriteLine($"[INFO] {message} at {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
}
public void LogError(string message, Exception ex)
{
Console.WriteLine($"[ERROR] {message}: {ex.Message} at {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
}
}
public class OrderService
{
private readonly ILogger _logger;
public OrderService(ILogger logger)
{
_logger = logger;
}
public void CreateOrder(Order order)
{
try
{
// Order creation logic
_logger.LogInfo($"Order {order.Id} created");
}
catch (Exception ex)
{
_logger.LogError("Failed to create order", ex);
throw;
}
}
}
Now you can swap ConsoleLogger for FileLogger or DatabaseLogger without touching your service classes.
Configuration Values Scattered Everywhere
Here's another common scenario:
public class EmailService
{
public void SendWelcomeEmail(string to)
{
var client = new SmtpClient("smtp.company.com", 587);
// Send email...
}
}
public class NotificationService
{
public void SendAlert(string to)
{
var client = new SmtpClient("smtp.company.com", 587);
// Send notification...
}
}
public class ReportService
{
public void SendReport(string to)
{
var client = new SmtpClient("smtp.company.com", 587);
// Send report...
}
}
The SMTP configuration is hardcoded three times. When you move from development to production, you'll hunt for every instance.
Solution: Configuration Class
public static class AppConfig
{
public static class Email
{
public const string SmtpHost = "smtp.company.com";
public const int SmtpPort = 587;
public const int MaxRetries = 3;
public const int TimeoutSeconds = 30;
}
}
public class EmailService
{
private readonly SmtpClient _client;
public EmailService()
{
_client = new SmtpClient(AppConfig.Email.SmtpHost,
AppConfig.Email.SmtpPort)
{
Timeout = AppConfig.Email.TimeoutSeconds * 1000
};
}
}
Even better, load from appsettings.json using IConfiguration for true external configuration.
Repeated Null Checks and Validation
Look at this pattern:
public class UserService
{
public void UpdateUserName(User user, string newName)
{
if (user == null)
throw new ArgumentNullException(nameof(user));
if (string.IsNullOrWhiteSpace(newName))
throw new ArgumentException("Name cannot be empty");
user.Name = newName;
}
public void UpdateUserEmail(User user, string newEmail)
{
if (user == null)
throw new ArgumentNullException(nameof(user));
if (string.IsNullOrWhiteSpace(newEmail))
throw new ArgumentException("Email cannot be empty");
user.Email = newEmail;
}
public void UpdateUserPhone(User user, string newPhone)
{
if (user == null)
throw new ArgumentNullException(nameof(user));
if (string.IsNullOrWhiteSpace(newPhone))
throw new ArgumentException("Phone cannot be empty");
user.Phone = newPhone;
}
}
The null-checking boilerplate is everywhere!
Solution: Guard Clauses Helper
public static class Guard
{
public static void AgainstNull(T value, string paramName) where T : class
{
if (value == null)
throw new ArgumentNullException(paramName);
}
public static void AgainstNullOrEmpty(string value, string paramName)
{
if (string.IsNullOrWhiteSpace(value))
throw new ArgumentException($"{paramName} cannot be empty", paramName);
}
}
public class UserService
{
public void UpdateUserName(User user, string newName)
{
Guard.AgainstNull(user, nameof(user));
Guard.AgainstNullOrEmpty(newName, nameof(newName));
user.Name = newName;
}
public void UpdateUserEmail(User user, string newEmail)
{
Guard.AgainstNull(user, nameof(user));
Guard.AgainstNullOrEmpty(newEmail, nameof(newEmail));
EmailValidator.ValidateOrThrow(newEmail);
user.Email = newEmail;
}
}
Much cleaner, and you can extend Guard with more validation patterns.
Database Connection Strings
This anti-pattern is everywhere:
public class CustomerRepository
{
public List GetAll()
{
using (var conn = new SqlConnection("Server=localhost;Database=MyDb;User Id=sa;Password=secret"))
{
// Query logic...
}
}
}
public class OrderRepository
{
public List GetAll()
{
using (var conn = new SqlConnection("Server=localhost;Database=MyDb;User Id=sa;Password=secret"))
{
// Query logic...
}
}
}
Don't do this! Security risk, maintenance nightmare, and deployment headache.
Solution: Dependency Injection with Configuration
public interface IDbConnectionFactory
{
SqlConnection CreateConnection();
}
public class SqlConnectionFactory : IDbConnectionFactory
{
private readonly string _connectionString;
public SqlConnectionFactory(IConfiguration configuration)
{
_connectionString = configuration.GetConnectionString("DefaultConnection");
}
public SqlConnection CreateConnection()
{
return new SqlConnection(_connectionString);
}
}
public class CustomerRepository
{
private readonly IDbConnectionFactory _connectionFactory;
public CustomerRepository(IDbConnectionFactory connectionFactory)
{
_connectionFactory = connectionFactory;
}
public List GetAll()
{
using (var conn = _connectionFactory.CreateConnection())
{
// Query logic...
}
}
}
Now connection strings live in one place (appsettings.json), and switching databases becomes trivial.
Conditional Logic for User Permissions
Consider permission checks scattered everywhere:
public void DeleteOrder(int orderId, User currentUser)
{
if (currentUser.Role != "Admin" && currentUser.Role != "Manager")
{
throw new UnauthorizedAccessException();
}
// Delete logic...
}
public void ApproveInvoice(int invoiceId, User currentUser)
{
if (currentUser.Role != "Admin" && currentUser.Role != "Manager")
{
throw new UnauthorizedAccessException();
}
// Approve logic...
}
Solution: Authorization Service
public interface IAuthorizationService
{
bool CanManageOrders(User user);
bool CanApproveInvoices(User user);
}
public class AuthorizationService : IAuthorizationService
{
private static readonly string[] ManagerRoles = { "Admin", "Manager" };
public bool CanManageOrders(User user)
{
return ManagerRoles.Contains(user.Role);
}
public bool CanApproveInvoices(User user)
{
return ManagerRoles.Contains(user.Role);
}
}
public class OrderService
{
private readonly IAuthorizationService _authService;
public OrderService(IAuthorizationService authService)
{
_authService = authService;
}
public void DeleteOrder(int orderId, User currentUser)
{
if (!_authService.CanManageOrders(currentUser))
{
throw new UnauthorizedAccessException();
}
// Delete logic...
}
}
When permission rules change, you update one service instead of hunting through dozens of methods.
DRY and KISS Working Together
DRY and KISS (Keep It Simple, Stupid) are complementary principles. DRY eliminates duplication, while KISS ensures the abstraction remains simple and understandable.
Consider this example that violates both:
public void ProcessOrderPayment(Order order)
{
if (order.Total > 0)
{
// Payment logic repeated
var gateway = new PaymentGateway("api-key-12345");
gateway.ProcessPayment(order.Total);
}
}
public void ProcessRefund(Order order)
{
if (order.Total > 0)
{
// Payment logic repeated
var gateway = new PaymentGateway("api-key-12345");
gateway.ProcessRefund(order.Total);
}
}
Apply DRY and KISS together
public interface IPaymentService
{
void ProcessPayment(decimal amount);
void ProcessRefund(decimal amount);
}
public class PaymentService : IPaymentService
{
private readonly PaymentGateway _gateway;
public PaymentService(IConfiguration config)
{
var apiKey = config["PaymentGateway:ApiKey"];
_gateway = new PaymentGateway(apiKey);
}
public void ProcessPayment(decimal amount)
{
if (amount <= 0)
throw new ArgumentException("Amount must be positive");
_gateway.ProcessPayment(amount);
}
public void ProcessRefund(decimal amount)
{
if (amount <= 0)
throw new ArgumentException("Amount must be positive");
_gateway.ProcessRefund(amount);
}
}
public class OrderService
{
private readonly IPaymentService _paymentService;
public OrderService(IPaymentService paymentService)
{
_paymentService = paymentService;
}
public void ProcessOrderPayment(Order order)
{
_paymentService.ProcessPayment(order.Total);
}
public void ProcessRefund(Order order)
{
_paymentService.ProcessRefund(order.Total);
}
}
Now the payment logic exists in one place (DRY), and each class is simple and focused (KISS).
DRY and SOLID: Building Maintainable Systems
DRY works beautifully with SOLID principles, especially the Single Responsibility Principle (SRP).
DRY + Single Responsibility Principle
Violating both DRY and SRP:
public class InvoiceProcessor
{
public void ProcessInvoice(Invoice invoice)
{
// Validation (repeated across methods)
if (invoice.Amount <= 0)
throw new Exception("Invalid amount");
// Email logic (repeated)
var smtp = new SmtpClient("smtp.server.com");
smtp.Send("[email protected]", invoice.CustomerEmail, "Invoice", "Your invoice");
// Database save (repeated)
var conn = new SqlConnection("connection-string");
// Save logic...
// PDF generation (repeated)
var pdf = new PdfGenerator();
pdf.Generate(invoice);
}
}
Apply DRY and SRP
public class InvoiceValidator
{
public void Validate(Invoice invoice)
{
if (invoice.Amount <= 0)
throw new ArgumentException("Invalid amount");
if (string.IsNullOrEmpty(invoice.CustomerEmail))
throw new ArgumentException("Customer email required");
}
}
public interface IEmailService
{
void SendInvoiceEmail(string to, Invoice invoice);
}
public interface IInvoiceRepository
{
void Save(Invoice invoice);
}
public interface IPdfGenerator
{
byte[] Generate(Invoice invoice);
}
public class InvoiceProcessor
{
private readonly InvoiceValidator _validator;
private readonly IEmailService _emailService;
private readonly IInvoiceRepository _repository;
private readonly IPdfGenerator _pdfGenerator;
public InvoiceProcessor(
InvoiceValidator validator,
IEmailService emailService,
IInvoiceRepository repository,
IPdfGenerator pdfGenerator)
{
_validator = validator;
_emailService = emailService;
_repository = repository;
_pdfGenerator = pdfGenerator;
}
public void ProcessInvoice(Invoice invoice)
{
_validator.Validate(invoice);
_repository.Save(invoice);
var pdf = _pdfGenerator.Generate(invoice);
_emailService.SendInvoiceEmail(invoice.CustomerEmail, invoice);
}
}
Each responsibility is separated (SRP), and each piece of logic exists once (DRY).
When NOT to Apply DRY
DRY isn't about eliminating every line of duplicate code. Sometimes duplication is acceptable:
Accidental duplication: Two pieces of code look similar now but represent different concepts that will evolve independently.
Premature abstraction: Don't abstract until you have three or more instances of duplication (Rule of Three).
Over-abstraction: Creating complex abstractions to eliminate minor duplication can make code harder to understand.
Different contexts: Similar logic in completely different bounded contexts (e.g., billing vs. inventory) may be better kept separate.
The key is: abstract when the duplication represents the same business concept, not just similar-looking code.
Conclusion
The DRY principle is fundamental to writing maintainable C# code. By eliminating duplication, you create single sources of truth that make your codebase easier to understand, modify, and debug. When business rules change, you change them once. When bugs appear, you fix them once.
Combining DRY with KISS and SOLID principles creates a powerful framework for professional software development. Remember that DRY isn't just about reducing lines of codeβit's about ensuring that each piece of knowledge in your system has exactly one authoritative representation.
As you continue your development journey, train yourself to spot duplication. When you find yourself copying code, pause and ask: "Can I extract this into a shared component?" More often than not, the answer is yes, and your future self will thank you.
Key Takeaways
β
Extract validation into reusable helpers and validators
β
Centralize configuration in constants or configuration files
β
Use dependency injection to avoid hardcoded dependencies
β
Create guard clauses for common argument validation
β
Abstract infrastructure (logging, database) behind interfaces
β
Consolidate business rules in dedicated services
β
Follow the Rule of Three before abstracting
β
Combine DRY with KISS and SOLID for maintainable code
What's the most painful duplication you've encountered in your codebase? Have you successfully refactored duplicated code? Share your experiences in the comments below!
References and Further Reading