Write Cleaner, Maintainable Code with DRY, KISS & SOLID Principles

Struggling with messy, hard-to-understand code? Learn 3 essential design principles (DRY, KISS, SOLID) to write clean, maintainable code as you start programming.
Write Cleaner, Maintainable Code with DRY, KISS & SOLID Principles

As you start learning to program, it's important to understand some key design principles that will help you write better, cleaner code. Let's look at three important principles: DRY, KISS, and SOLID.

DRY (Don't Repeat Yourself)

Avoids code duplication, promoting cleaner and more maintainable code.

  • Avoid copying and pasting code.
  • If you find yourself writing the same thing multiple times, create a function instead.
  • This makes your code easier to maintain and update.

Bad example (repeating code):

def greet_john
  puts "Hello, John!"
  puts "How are you today?"
end

def greet_mary
  puts "Hello, Mary!"
  puts "How are you today?"
end

Better example (using a method):

def greet(name)
  puts "Hello, #{name}!"
  puts "How are you today?"
end

greet("John")
greet("Mary")

KISS (Keep It Simple, Stupid)

Emphasizes simplicity and clarity in code design, making it easier to understand and modify.

  • Write clear, straightforward code
  • Avoid unnecessary complexity
  • Simple solutions are often the best

Here's an example with a mistake that can be optimized following KISS:

def is_weekend?(day)
  if day == "Saturday" || day == "Sunday"
    puts "It's the weekend!"
    return true
  else
    puts "It's a weekday."
    return false
  end
end

# Usage (assuming today is Wednesday)
day = "Wednesday"
is_weekend?(day)

This code works, but it's a bit verbose and repetitive. It prints messages within the function which might not be always desired.
KISS Optimization:

def is_weekend?(day)
  day == "Saturday" || day == "Sunday"
end

# Usage
day = "Wednesday"
if is_weekend?(day)
  puts "It's the weekend!"
else
  puts "It's a weekday."
end

Here's how it follows KISS:

  • Removed unnecessary output: The function itself no longer prints messages, keeping it focused on logic.
  • Simplified logic: The return statements were removed since Ruby implicitly returns the last expression evaluated.
  • Cleaner usage: The logic for printing messages is separated from the function, making the code more readable and maintainable.

This optimized code achieves the same functionality (checking if it's the weekend) with fewer lines and avoids unnecessary complexity within the function.

SOLID

Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion: A set of five principles that promote well-structured, maintainable, and flexible object-oriented code.

Single Responsibility Principle

Each part of your code should do one thing well.
Bad example (multiple responsibilities):

class User
  attr_reader :name

  def initialize(name)
    @name = name
  end

  def save_to_database
    # Code to save user to database
  end

  def generate_report
    # Code to generate a report
  end
end

Better example:

class User
  attr_reader :name

  def initialize(name)
    @name = name
  end
end

class UserDatabase
  def save_user(user)
    # Code to save user to database
  end
end

class ReportGenerator
  def generate_user_report(user)
    # Code to generate a report
  end
end

Open-Closed Principle

Your code (classes, modules, functions, etc.) should be open for extension but closed for modification. This means you should be able to extend a class's behavior without modifying its existing code.

class Shape
  def area
    raise NotImplementedError, "Subclass must implement abstract method"
  end
end

class Rectangle < Shape
  def initialize(width, height)
    @width = width
    @height = height
  end

  def area
    @width * @height
  end
end

class Circle < Shape
  def initialize(radius)
    @radius = radius
  end

  def area
    Math::PI * @radius**2
  end
end
  • The Shape class is abstract and defines an interface with the area method.
  • Rectangle and Circle classes extend Shape and implement their own area methods.
  • New shapes can be added by creating new subclasses of Shape without modifying existing code.

This structure allows you to add new shapes without changing the existing Shape, Rectangle, or Circle classes.

Liskov Substitution Principle

Subclasses should be able to replace their parent classes without breaking the code. In other words, a subclass should be able to do everything its parent class can do.

class Bird
  def fly
    raise NotImplementedError, "Subclass must implement abstract method"
  end
end

class Sparrow < Bird
  def fly
    puts "Sparrow flying"
  end
end

class Ostrich < Bird
  def fly
    raise "Ostriches can't fly"  # This violates LSP
  end
end

In the given code:

  • The Bird class defines a fly method that's meant to be implemented by subclasses.
  • The Sparrow class implements fly correctly.
  • The Ostrich class violates LSP because it can't fulfill the fly behavior expected from a Bird.

The problem here is that the Bird class assumes all birds can fly, which isn't true in reality. To adapt this code to follow LSP, we need to redesign our class hierarchy to better represent the capabilities of different types of birds.

Here's how we can modify the code to adhere to LSP:

class Bird
  def move
    raise NotImplementedError, "Subclass must implement abstract method"
  end
end

class FlyingBird < Bird
  def move
    fly
  end

  def fly
    raise NotImplementedError, "Subclass must implement abstract method"
  end
end

class NonFlyingBird < Bird
  def move
    run
  end

  def run
    raise NotImplementedError, "Subclass must implement abstract method"
  end
end

class Sparrow < FlyingBird
  def fly
    puts "Sparrow flying"
  end
end

class Ostrich < NonFlyingBird
  def run
    puts "Ostrich running"
  end
end

In this revised version:

  • We have a base Bird class with a generic move method.
  • We create two subclasses: FlyingBird and NonFlyingBird.
  • FlyingBird implements move as fly, while NonFlyingBird implements it as run.
  • Sparrow inherits from FlyingBird and implements fly.
  • Ostrich inherits from NonFlyingBird and implements run.

Now, this hierarchy respects LSP because:

  • All birds can move, maintaining the base class contract.
  • Flying birds implement fly, and non-flying birds implement run.
  • We can use any Bird subclass where a Bird is expected, and the move method will work correctly for all of them.

Interface Segregation Principle

Many specific interfaces are better than one general interface.

Scenario: Imagine a library management system with different types of media (books, audiobooks, movies). We want to represent them with classes and define functionalities for borrowing and returning items.
Without ISP:

module Borrowable
  def borrow
    raise NotImplementedError, "Classes must implement borrow method"
  end

  def return_item
    raise NotImplementedError, "Classes must implement return_item method"
  end
end

class Book
  include Borrowable

  def borrow
    puts "Borrowing a book"
  end

  def return_item
    puts "Returning a book"
  end
end

class Audiobook
  include Borrowable

  def borrow
    puts "Borrowing an audiobook (download or physical copy)"
  end

  def return_item
    puts "Returning an audiobook"
  end
end

class Movie
  include Borrowable

  def borrow
    puts "Borrowing a movie (rental or streaming)"
  end

  def return_item
    puts "Returning a movie"
  end
end

This code works, but it violates ISP because:

  • The Borrowable module forces all classes to implement return_item, even though movies might not have a physical item to return (depending on rental or streaming).

With ISP:

module Borrowable
  def borrow
    raise NotImplementedError, "Classes must implement borrow method"
  end
end

module PhysicalItem
  def return_item
    raise NotImplementedError, "Classes must implement return_item method"
  end
end

class Book
  include Borrowable
  include PhysicalItem

  def borrow
    puts "Borrowing a book"
  end

  def return_item
    puts "Returning a book"
  end
end

class Audiobook
  include Borrowable

  def borrow
    puts "Borrowing an audiobook (download or physical copy)"
  end
# No return_item method since it might not be applicable
end

class Movie
  include Borrowable

  def borrow
    puts "Borrowing a movie (rental or streaming)"
  end
# No return_item method since it might not be applicable
end

This approach adheres to ISP by:

  • Smaller, focused modules: Borrowable handles borrowing, and PhysicalItem handles physical item return (optional for some classes).
  • Classes depend on what they need: Book includes both modules, while Audiobook and Movie only include Borrowable.

This improves code maintainability and flexibility. You can add new media types (e.g., ebooks) with specific borrowing functionalities without modifying existing classes.

Dependency Inversion Principle

Depend on abstractions, not concrete implementations. Dependency Inversion Principle (DIP) says high-level parts of your code shouldn't rely on specific details of lower-level parts. Instead, they should both rely on a common "blueprint" (interface). This lets you change or swap lower-level parts without affecting the higher-level ones, keeping your code flexible and easier to maintain.

Scenario: Imagine a notification system that can send different types of notifications (email, SMS, push notification). We want to decouple the notification sender from the specific notification type.
Without DIP:

class NotificationSender
  def initialize(recipient)
    @recipient = recipient
  end

  def send_email_notification(message)
    puts "Sending email notification to #{@recipient} with message: #{message}"
  end

  def send_sms_notification(message)
    puts "Sending SMS notification to #{@recipient} with message: #{message}"
  end
end

# Usage
sender = NotificationSender.new("[email protected]")
sender.send_email_notification("Welcome message!")

This code works, but it violates DIP because:

  • The NotificationSender class is tightly coupled to specific notification types (email, SMS). Adding a new notification type (e.g., push) requires modifying this class.

With DIP:

interface Notification
  def send(recipient, message)
    raise NotImplementedError, "Subclasses must implement send method"
  end
end

class EmailNotification
  include Notification

  def send(recipient, message)
    puts "Sending email notification to #{recipient} with message: #{message}"
  end
end

class SMSNotification
  include Notification

  def send(recipient, message)
    puts "Sending SMS notification to #{recipient} with message: #{message}"
  end
end

# Add more notification classes like PushNotification (similar structure)

class NotificationSender
  def initialize(notification)
    @notification = notification
  end

  def send_notification(recipient, message)
    @notification.send(recipient, message)
  end
end

email_notification = EmailNotification.new
sms_notification = SMSNotification.new

sender = NotificationSender.new(email_notification)
sender.send_notification("[email protected]", "Welcome message!")

# Use the sender with different notification types by injecting them
sender = NotificationSender.new(sms_notification)
sender.send_notification("+84877698945", "Your order has shipped!")

How it adheres to DIP:

  • High-level modules (NotificationSender): Depend on abstractions (Notification).
  • Low-level modules (EmailNotification, SMSNotification): Implement the abstraction.
  • Details (Concrete Notification Type): Injected through the constructor, making the sender independent of specific notification classes.

This approach offers several benefits:

  • Flexibility: Easily add new notification types without modifying the NotificationSender.
  • Testability: Easier to test the NotificationSender in isolation by mocking different notification implementations.
  • Maintainability: Code becomes more modular and easier to understand.

By following DIP, you create a more adaptable and maintainable notification system.

Relationship

  • DRY and KISS often complement each other: Removing redundancy (DRY) often leads to simpler code (KISS).
  • SOLID principles build upon DRY and KISS: Well-designed SOLID code inherently avoids duplication and promotes simplicity.

Advantages and Disadvantages

Advantages

  • DRY: Reduces errors, simplifies maintenance, and improves code readability.
  • KISS: Easier debugging, faster development, and less room for misinterpretation.
  • SOLID: Promotes modularity, reusability, loose coupling, and easier testing.

Disadvantages

  • DRY: Might lead to over-engineering or creating complex abstractions for very simple tasks.
  • KISS: Can be subjective and might limit functionality in complex applications.
  • SOLID: Requires more planning and upfront effort compared to simpler approaches.

Remember, these principles are guidelines, not strict rules. Use your judgment to find the right balance based on your project's complexity and requirements. These principles are guidelines to help you write better code. As you practice, they'll become more natural to apply.