Cheat SheetsLow Level DesignSOLID Principles in Practice

SOLID Principles in Practice — Cheat Sheet

Low Level Design · 5 topics. Download the PDF or the Instagram carousel and share it.

Cheat Sheet · AiCanCode.org
SOLID Principles in Practice
Low Level Design5 topicsQuick revision reference
1

Single Responsibility Principle

A class should have only one reason to change — it should do one thing and do it well.

  • A class should have one reason to change — one axis of variation, one responsibility.
  • God classes that do everything are the most common SRP violation.
  • SRP improves testability — small, focused classes are easier to unit test in isolation.
  • SRP applies at method level too — methods should do one thing, named as a verb-noun describing that one thing.
  • SRP and high cohesion are the same idea: group things that change together.
Java — SRP violation and refactoring
// ❌ SRP Violation: UserService does too many things
public class UserService {
    public void registerUser(String email, String password) {
        // 1. Validate
        if (!email.contains("@")) throw new IllegalArgumentException("Invalid email");
        // 2. Hash password
        String hashed = BCrypt.hashpw(password, BCrypt.gensalt());
        // 3. Save to database
        String sql = "INSERT INTO users (email, password) VALUES (?, ?)";
        jdbcTemplate.update(sql, email, hashed);
        // 4. Send welcome email
        MimeMessage msg = mailSender.createMimeMessage();
        // ... email setup ...
        mailSender.send(msg);
        // 5. Generate PDF welcome packet
        Document doc = new Document();
        PdfWriter.getInstance(doc, new FileOutputStream("welcome.pdf"));
        // ... PDF generation ...
    }
    // This class changes when: email template changes, PDF library upgrades,
    // DB schema changes, validation rules change, or password policy changes
}

// ✅ SRP Refactoring: one class, one responsibility
public class UserRegistrationService {   // orchestrates only
    private final UserRepository   userRepo;
    private final PasswordEncoder  encoder;
    private final EmailService     emailService;
    private final WelcomeDocService docService;

    public UserRegistrationService(UserRepository userRepo, PasswordEncoder encoder,
                                   EmailService emailService, WelcomeDocService docService) {
        this.userRepo     = userRepo;
        this.encoder      = encoder;
        this.emailService = emailService;
        this.docService   = docService;
    }

    public User registerUser(String email, String password) {
        User user = User.of(email, encoder.encode(password));
        userRepo.save(user);
        emailService.sendWelcome(user);
        docService.generateWelcomePacket(user);
        return user;
    }
}

// Each extracted class has one reason to change:
public class EmailService {
    public void sendWelcome(User user) { /* only email logic here */ }
}

public class WelcomeDocService {
    public void generateWelcomePacket(User user) { /* only PDF logic here */ }
}

public class UserRepository {
    public void save(User user) { /* only DB logic here */ }
}
2

Open/Closed Principle

Software entities should be open for extension but closed for modification — add new behavior by writing new code, not changing existing code.

  • Open for extension = add new behavior by writing new classes/methods.
  • Closed for modification = existing tested code is not changed.
  • Strategy and Template Method are the primary OCP enablers in Java.
  • The key is identifying the right extension point — abstract over what varies.
  • 100% OCP is impossible — aim to protect the most volatile variation points.
Java — OCP with Strategy (Discount Calculator)
// ❌ OCP Violation: modify this class every time a new discount type is added
public class DiscountCalculator {
    public double calculate(Order order) {
        double discount = 0;
        if (order.getType() == OrderType.SEASONAL) {
            discount = order.getTotal() * 0.10;
        } else if (order.getType() == OrderType.EMPLOYEE) {
            discount = order.getTotal() * 0.20;
        } else if (order.getType() == OrderType.BULK) {
            discount = order.getQuantity() > 10 ? order.getTotal() * 0.15 : 0;
        }
        // Adding a new type requires editing this class — OCP violation
        return discount;
    }
}

// ✅ OCP with Strategy pattern
@FunctionalInterface
public interface DiscountStrategy {
    double calculate(Order order);
}

// Each discount type is a new class — no modification needed
public class SeasonalDiscount implements DiscountStrategy {
    @Override public double calculate(Order o) { return o.getTotal() * 0.10; }
}

public class EmployeeDiscount implements DiscountStrategy {
    @Override public double calculate(Order o) { return o.getTotal() * 0.20; }
}

public class BulkDiscount implements DiscountStrategy {
    @Override
    public double calculate(Order o) {
        return o.getQuantity() > 10 ? o.getTotal() * 0.15 : 0;
    }
}

// Closed for modification — never needs to change for new discount types
public class DiscountCalculator {
    private final List<DiscountStrategy> strategies;

    public DiscountCalculator(List<DiscountStrategy> strategies) {
        this.strategies = strategies;
    }

    public double calculate(Order order) {
        return strategies.stream()
                         .mapToDouble(s -> s.calculate(order))
                         .sum();
    }
}

// Adding a loyalty discount: create a new class, register it — zero modification
public class LoyaltyDiscount implements DiscountStrategy {
    @Override public double calculate(Order o) {
        return o.getLoyaltyPoints() > 1000 ? o.getTotal() * 0.05 : 0;
    }
}
3

Liskov Substitution Principle

Subtypes must be substitutable for their base types without altering the correctness of the program.

  • Subtypes must be substitutable for their base type without breaking program correctness.
  • Square-Rectangle is the canonical LSP violation — Square breaks Rectangle's independent-dimensions invariant.
  • Subclasses may only weaken preconditions (accept more) and strengthen postconditions (return more).
  • Throwing new unchecked exceptions for valid parent inputs violates LSP.
  • instanceof checks in client code are a red flag — they indicate the caller knows about subtypes, signaling an LSP problem.
Java — LSP violation (Square-Rectangle) and fix
// ❌ Classic LSP violation
public class Rectangle {
    protected int width;
    protected int height;

    public void setWidth(int width)   { this.width = width; }
    public void setHeight(int height) { this.height = height; }
    public int  area()                { return width * height; }
}

public class Square extends Rectangle {
    @Override
    public void setWidth(int width) {
        this.width  = width;
        this.height = width; // side effect — violates Rectangle contract!
    }

    @Override
    public void setHeight(int height) {
        this.width  = height; // side effect
        this.height = height;
    }
}

// Client code that breaks with Square
public void testRectangle(Rectangle r) {
    r.setWidth(5);
    r.setHeight(4);
    assert r.area() == 20 : "Expected 20, got " + r.area();
    // Passes for Rectangle — fails for Square (area = 16 not 20)!
}

testRectangle(new Rectangle()); // ✅ passes
testRectangle(new Square());    // ❌ assertion fails — LSP violated

// ✅ Fix: do not use inheritance; use separate classes with a common interface
public interface Shape {
    int area();
}

public final class Rectangle implements Shape {
    private final int width, height;
    public Rectangle(int width, int height) { this.width = width; this.height = height; }
    @Override public int area() { return width * height; }
}

public final class Square implements Shape {
    private final int side;
    public Square(int side) { this.side = side; }
    @Override public int area() { return side * side; }
}
4

Interface Segregation Principle

Clients should not be forced to depend on methods they do not use — prefer many small, role-specific interfaces over one fat interface.

  • Fat interfaces force implementing classes to provide empty/unsupported stubs — violating ISP.
  • Split fat interfaces into role interfaces — each interface has one cohesive purpose.
  • Java's Readable, Writable, and Closeable are role interfaces (ISP in java.io).
  • Spring Data CrudRepository → PagingAndSortingRepository → JpaRepository is a graduated ISP hierarchy.
  • ISP is the interface-level application of SRP — both are about cohesion.
Java — ISP: Fat interface to role interfaces
// ❌ ISP Violation: fat interface
public interface Worker {
    void work();
    void eat();      // not applicable to robots
    void sleep();    // not applicable to robots
    void attendMeeting();
}

public class HumanWorker implements Worker {
    @Override public void work()           { System.out.println("Human working"); }
    @Override public void eat()            { System.out.println("Human eating"); }
    @Override public void sleep()          { System.out.println("Human sleeping"); }
    @Override public void attendMeeting()  { System.out.println("Human in meeting"); }
}

public class RobotWorker implements Worker {
    @Override public void work()           { System.out.println("Robot working"); }
    @Override public void eat()            { throw new UnsupportedOperationException("Robots don't eat!"); }
    @Override public void sleep()          { throw new UnsupportedOperationException("Robots don't sleep!"); }
    @Override public void attendMeeting()  { /* robots attend via video? */ }
}

// ✅ ISP Fix: role interfaces
public interface Workable       { void work(); }
public interface Feedable       { void eat(); }
public interface Restable       { void sleep(); }
public interface MeetingCapable { void attendMeeting(); }

// Human implements all roles it needs
public class HumanWorker implements Workable, Feedable, Restable, MeetingCapable {
    @Override public void work()          { System.out.println("Human working"); }
    @Override public void eat()           { System.out.println("Human eating"); }
    @Override public void sleep()         { System.out.println("Human sleeping"); }
    @Override public void attendMeeting() { System.out.println("Human in meeting"); }
}

// Robot only implements what it can do — no stubs, no exceptions
public class RobotWorker implements Workable, MeetingCapable {
    @Override public void work()          { System.out.println("Robot working"); }
    @Override public void attendMeeting() { System.out.println("Robot attending via stream"); }
}

// Client depends only on the role it needs
public class WorkScheduler {
    private final List<Workable> workers;
    public WorkScheduler(List<Workable> workers) { this.workers = workers; }
    public void startWork() { workers.forEach(Workable::work); }
    // Does not care if worker is Human or Robot — depends only on Workable
}
5

Dependency Inversion Principle

High-level modules should not depend on low-level modules; both should depend on abstractions — and abstractions should not depend on details.

  • High-level modules must not depend on low-level modules — both depend on interfaces.
  • The interface is "owned" by the high-level module — low-level modules implement it.
  • DIP makes the high-level policy stable and immune to changes in low-level details.
  • Dependency Injection is the runtime mechanism that wires abstractions to implementations.
  • Without DIP, switching from MySQL to MongoDB requires modifying every high-level class that directly imports MySQL types.
Java — DIP with Repository interface and Spring DI
// ❌ DIP violation: high-level depends on low-level concrete class
public class OrderService {
    // Directly coupled to MySQL implementation
    private final MySqlOrderRepository repo = new MySqlOrderRepository();

    public void placeOrder(Order order) {
        repo.save(order); // tied to MySQL — cannot test without DB
    }
}

// ✅ DIP applied: both depend on abstraction
// 1. Define the abstraction (interface owned by the HIGH-LEVEL module)
public interface OrderRepository {
    void save(Order order);
    Optional<Order> findById(String id);
    List<Order> findByUserId(String userId);
}

// 2. Low-level module implements the abstraction
public class MySqlOrderRepository implements OrderRepository {
    private final JdbcTemplate jdbc;
    public MySqlOrderRepository(JdbcTemplate jdbc) { this.jdbc = jdbc; }

    @Override public void save(Order order) {
        jdbc.update("INSERT INTO orders (id, user_id, total) VALUES (?, ?, ?)",
                    order.getId(), order.getUserId(), order.getTotal());
    }

    @Override public Optional<Order> findById(String id) {
        return jdbc.query("SELECT * FROM orders WHERE id = ?",
                          ORDER_MAPPER, id).stream().findFirst();
    }

    @Override public List<Order> findByUserId(String userId) {
        return jdbc.query("SELECT * FROM orders WHERE user_id = ?", ORDER_MAPPER, userId);
    }
}

// 3. High-level module depends ONLY on the interface
@Service
public class OrderService {
    private final OrderRepository orderRepo;    // abstraction, not concrete
    private final PaymentGateway  payment;

    public OrderService(OrderRepository orderRepo, PaymentGateway payment) {
        this.orderRepo = orderRepo;
        this.payment   = payment;
    }

    public Order placeOrder(Cart cart) {
        Order order = Order.from(cart);
        payment.charge(order.getUserId(), order.getTotal());
        orderRepo.save(order);
        return order;
    }
}

// 4. Spring wires them (DI applies DIP at runtime)
@Configuration
public class DataConfig {
    @Bean
    public OrderRepository orderRepository(JdbcTemplate jdbc) {
        return new MySqlOrderRepository(jdbc);
        // Swap to PostgreSQL: return new PostgresOrderRepository(jdbc);
        // Swap to MongoDB: return new MongoOrderRepository(mongoTemplate);
    }
}

// 5. In tests: inject an in-memory fake — zero database needed
class OrderServiceTest {
    @Test void placeOrder_savesOrder() {
        List<Order> savedOrders = new ArrayList<>();
        OrderRepository fakeRepo = new OrderRepository() {
            @Override public void save(Order o) { savedOrders.add(o); }
            @Override public Optional<Order> findById(String id) { return Optional.empty(); }
            @Override public List<Order> findByUserId(String uid) { return List.of(); }
        };
        OrderService sut = new OrderService(fakeRepo, new FakePaymentGateway());
        sut.placeOrder(Cart.withItems(List.of(new Item("Book", 499.0))));
        assertEquals(1, savedOrders.size());
    }
}
Learn this free with Aria, your AI tutor → AiCanCode.org/learn/lld