Building Robust Software: An Introduction to SOLID and DRY Principles
Writing software that works is one thing; writing software that is easy to understand, maintain, extend, and test is another. As applications grow in complexity, adhering to sound design principles becomes crucial for long-term success and sanity. Two fundamental sets of guidelines stand out: the SOLID principles for object-oriented design and the DRY principle.
Let’s break down these concepts to understand how they help us build better software.
The SOLID Principles
Coined by Robert C. Martin (Uncle Bob), SOLID is an acronym representing five core design principles that foster more understandable, flexible, and maintainable object-oriented systems.
1. S - Single Responsibility Principle (SRP)
A class should have only one reason to change.
Meaning: A class should be responsible for one specific piece of functionality or concern within the application. If a class handles multiple unrelated responsibilities (e.g., fetching data, manipulating it, and formatting it for display), changes to any one of those responsibilities will require modifying the class. This increases the risk of introducing bugs and makes the class harder to understand and test.
Why: Promotes high cohesion, reduces coupling, improves readability and testability.
Example (TypeScript):
// VIOLATION of SRP
class Report {
getData(): any[] {
console.log("Fetching data from database...");
const data = [{ item: 'A', value: 10 }, { item: 'B', value: 20 }];
return data;
}
formatAsJson(data: any[]): string {
console.log("Formatting data as JSON...");
return JSON.stringify(data, null, 2);
}
// 👎 This class does too much: data access AND formatting.
// Changes to data source OR format require changing this class.
generateReport(): string {
const data = this.getData();
const formatted = this.formatAsJson(data);
return formatted;
}
}
// ADHERENCE to SRP
interface ReportDataProvider {
getData(): any[];
}
interface ReportFormatter {
format(data: any[]): string;
}
class DatabaseReportProvider implements ReportDataProvider {
getData(): any[] {
console.log("Fetching data from database...");
return [{ item: 'A', value: 10 }, { item: 'B', value: 20 }];
}
}
class JsonReportFormatter implements ReportFormatter {
format(data: any[]): string {
console.log("Formatting data as JSON...");
return JSON.stringify(data, null, 2);
}
}
// This class now has a SINGLE responsibility: coordinating report generation.
class ReportGenerator {
constructor(
private dataProvider: ReportDataProvider,
private formatter: ReportFormatter
) {}
generate(): string {
const data = this.dataProvider.getData();
const formattedReport = this.formatter.format(data);
// Could add saving logic here, perhaps by injecting a ReportSaver (SRP!)
console.log("Generating report...");
return formattedReport;
}
}
// Usage
const dbProvider = new DatabaseReportProvider();
const jsonFormatter = new JsonReportFormatter();
const reportGenerator = new ReportGenerator(dbProvider, jsonFormatter);
console.log(reportGenerator.generate());
2. O - Open/Closed Principle (OCP)
Software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification.
Meaning: You should be able to add new functionality without changing existing, working code. This is typically achieved through abstractions like interfaces or abstract classes. New features are added by creating new implementations or subclasses rather than altering stable code.
Why: Reduces the risk of introducing bugs into existing functionality, promotes code stability, improves maintainability and flexibility.
(See previous article draft for the Shape/AreaCalculator example demonstrating OCP violation and adherence using interfaces).
3. L - Liskov Substitution Principle (LSP)
Subtypes must be substitutable for their base types without altering the correctness of the program.
Meaning: If you have a function or class that works with a base type (T
), it should also work correctly if you pass in an object of a derived type (S
), without needing to know it’s specifically type S
. Subclasses should honor the contract (behavior, invariants) defined by their parent class or interface.
Why: Ensures that inheritance and polymorphism work reliably. Violating LSP often leads to conditional logic (if (obj instanceof SubType)
) scattered through the code, which breaks OCP.
(See previous article draft for the Rectangle/Square example demonstrating LSP violation and adherence, highlighting how the Square subtype breaks the Rectangle’s contract).
4. I - Interface Segregation Principle (ISP)
Clients should not be forced to depend on methods they do not use.
Meaning: Interfaces should be fine-grained and specific to the client’s needs. Avoid creating large, “fat” interfaces that bundle unrelated methods. If a class implements an interface but doesn’t need some of its methods (perhaps throwing NotImplementedError
), it’s a sign of an ISP violation.
Why: Reduces coupling, improves cohesion, makes implementations simpler, prevents unnecessary dependencies.
Example (TypeScript):
// VIOLATION of ISP
interface WorkerTasks {
work(): void;
eat(): void;
sleep(): void;
}
class HumanWorker implements WorkerTasks {
work(): void { console.log("Human working..."); }
eat(): void { console.log("Human eating..."); }
sleep(): void { console.log("Human sleeping..."); }
}
class RobotWorker implements WorkerTasks {
work(): void { console.log("Robot working..."); }
// 👎 Robot doesn't eat or sleep, forced to implement these.
eat(): void {
// throw new Error("Robots don't eat!"); // Or just do nothing
}
sleep(): void {
// throw new Error("Robots don't sleep!"); // Or just do nothing
}
}
// ADHERENCE to ISP
interface Workable {
work(): void;
}
interface Eatable {
eat(): void;
}
interface Sleepable {
sleep(): void;
}
// Implement only relevant interfaces
class HumanWorkerISP implements Workable, Eatable, Sleepable {
work(): void { console.log("Human working..."); }
eat(): void { console.log("Human eating..."); }
sleep(): void { console.log("Human sleeping..."); }
}
// ✅ Robot only implements what it can do
class RobotWorkerISP implements Workable {
work(): void { console.log("Robot working..."); }
}
// Client code can depend on the specific interface needed
function manageWorker(worker: Workable) {
worker.work();
}
const human = new HumanWorkerISP();
const robot = new RobotWorkerISP();
manageWorker(human); // Works
manageWorker(robot); // Works
// If you needed eating behavior, you'd require an Eatable instance.
5. D - Dependency Inversion Principle (DIP)
- High-level modules should not depend on low-level modules. Both should depend on abstractions (e.g., interfaces).
- Abstractions should not depend on details. Details (concrete implementations) should depend on abstractions.
Meaning: Instead of high-level policy code directly depending on specific low-level implementation details (like directly new
-ing up a concrete database repository or email sender), both should depend on a common abstraction (an interface). This “inverts” the typical dependency flow. Dependency Injection (DI) is a common technique to achieve this.
Why: Decouples high-level logic from low-level implementation details, making the system vastly more flexible, testable (easy to substitute mock implementations), and maintainable. Allows swapping implementations easily.
Example (TypeScript):
// VIOLATION of DIP
class EmailSender { // Low-level module (detail)
send(message: string): void {
console.log(`Sending email: ${message}`);
}
}
class NotificationService { // High-level module
private emailSender: EmailSender; // 👎 Direct dependency on concrete low-level module
constructor() {
this.emailSender = new EmailSender(); // 👎 Instantiation inside high-level module
}
notifyUser(userId: string, message: string): void {
console.log(`Notifying user ${userId}...`);
this.emailSender.send(message); // Depends on the detail
// What if we want to use SMS? Requires modifying this class!
}
}
// ADHERENCE to DIP
interface Notifier { // Abstraction
send(message: string): void;
}
class EmailNotifier implements Notifier { // Detail depends on abstraction
send(message: string): void {
console.log(`Sending email: ${message}`);
}
}
class SmsNotifier implements Notifier { // Detail depends on abstraction
send(message: string): void {
console.log(`Sending SMS: ${message}`);
}
}
class NotificationServiceDIP { // High-level module depends on abstraction
// ✅ Depends on the Notifier interface, not a concrete class
constructor(private notifier: Notifier) {} // Dependency is injected
notifyUser(userId: string, message: string): void {
console.log(`Notifying user ${userId}...`);
this.notifier.send(message); // Uses the abstraction
}
}
// Usage (Dependency Injection)
const emailNotifier = new EmailNotifier();
const smsNotifier = new SmsNotifier();
// Inject the desired implementation
const emailNotificationService = new NotificationServiceDIP(emailNotifier);
emailNotificationService.notifyUser('user1', 'Your order shipped!');
const smsNotificationService = new NotificationServiceDIP(smsNotifier);
smsNotificationService.notifyUser('user2', 'Your appointment is confirmed.');
The DRY Principle: Don’t Repeat Yourself
Every piece of knowledge must have a single, unambiguous, authoritative representation within a system.
Meaning: Avoid duplicating the same logic, configuration, or data in multiple places. If you find yourself copying and pasting code, it’s often a sign that you should extract that logic into a reusable function, class, or module.
Why:
- Maintainability: When logic needs to change, you only need to change it in one place.
- Reduced Errors: Fixing a bug in the single authoritative place fixes it everywhere. Duplication means bugs must be fixed multiple times, increasing the chance of missing one.
- Readability: Well-factored code without repetition is often easier to understand.
Example (TypeScript):
// VIOLATION of DRY
function calculateTotalPriceWithTax(items: number[], taxRate: number): number {
let subtotal = 0;
for (const itemPrice of items) {
subtotal += itemPrice; // Duplicated summation logic
}
const taxAmount = subtotal * taxRate;
return subtotal + taxAmount;
}
function calculateAveragePrice(items: number[]): number {
if (items.length === 0) return 0;
let subtotal = 0;
for (const itemPrice of items) {
subtotal += itemPrice; // Duplicated summation logic
}
return subtotal / items.length;
}
// ADHERENCE to DRY
function calculateSubtotal(items: number[]): number {
let subtotal = 0;
for (const itemPrice of items) {
subtotal += itemPrice;
}
return subtotal;
}
function calculateTotalPriceWithTaxDRY(items: number[], taxRate: number): number {
const subtotal = calculateSubtotal(items); // ✅ Reuse common logic
const taxAmount = subtotal * taxRate;
return subtotal + taxAmount;
}
function calculateAveragePriceDRY(items: number[]): number {
if (items.length === 0) return 0;
const subtotal = calculateSubtotal(items); // ✅ Reuse common logic
return subtotal / items.length;
}
const prices = [10, 20, 30, 40];
console.log("Total with Tax (DRY):", calculateTotalPriceWithTaxDRY(prices, 0.1));
console.log("Average Price (DRY):", calculateAveragePriceDRY(prices));
Caution with DRY: Be mindful of “accidental duplication” where two pieces of code look similar now but represent different concepts that might evolve independently. Over-aggressive abstraction can sometimes be worse than a small amount of duplication if it creates incorrect or overly complex dependencies.
Conclusion
SOLID and DRY are not rigid laws but powerful guidelines that lead to significantly better software design. By striving for single responsibilities (SRP), openness to extension (OCP), substitutable subtypes (LSP), lean interfaces (ISP), dependency inversion (DIP), and avoiding repetition (DRY), you build systems that are easier to understand, test, adapt to change, and maintain over time. Embracing these principles is an investment in the long-term health and quality of your codebase.