logo
images.png

Software Design Principles Everyone Needs to Know

  • Author: Trần Trung
  • Published On: 18 May 2025
  • Category: System Design

Hello everyone. To write code that not only runs correctly but is also easy to fix, easy to develop further and helps the team work effectively, it is essential to understand and apply software design principles . These are important guidelines to help you organize your code in a reasonable way, avoiding creating complex software that is difficult to manage. Let's learn these principles!

Key and Useful Design Principles

These are sets of principles or tips that have been recognized and applied by many good programmers to improve software quality.

  1. SOLID: Five Tips for Writing Better Object-Oriented Code SOLID is an acronym for five tips that make object-oriented software design easier to understand, more flexible, and more maintainable. Mastering SOLID will help you write significantly cleaner and better-organized code.
    • S – Single Responsibility Principle:
      • "A class should have only one reason to change."
      • Meaning: Each class or small part of a program should do only one specific thing, one specific task. If a class does too many things, it will be difficult to understand, difficult to fix, and prone to errors when one of its tasks needs to change.
      • For example:

        // Bad: Employee class is doing too much
        class Employee {
            public void calculatePay() { /* ... */ }
            public void saveToDatabase() { /* ... */ }
            public void generateReport() { /* ... */ }
        }
        // Good: Each class has a single responsibility
        class EmployeePayCalculator {
            public void calculatePay(EmployeeData employeeData) { /* ... */ }
        }
        class EmployeeRepository {
            public void save(EmployeeData employeeData) { /* ... */ }
        }
        class EmployeeReportGenerator {
            public void generateReport(EmployeeData employeeData) { /* ... */ }
        }
        class EmployeeData { /* ... */ } // Represents employee information
        
      • Benefits: Code is easier to understand, easier to check for errors. When changes need to be made, you know where to fix them.
    • O – Open/Closed Principle:
      • "Software components (classes, modules, functions) should be easily extended to add new functionality, but should limit modification of existing code."
      • Meaning: You should be able to add new features to the system without having to rewrite existing code. The usual way to do this is to use abstract classes or interfaces.
      • For example, let's say you have a payment system.

        // Instead of modifying PaymentProcessor class for every new payment method:
        // if (type == "creditcard") { ... } else if (type == "paypal") { ... }
        // Better design following OCP:
        interface PaymentStrategy {
            void processPayment(double amount);
        }
        class CreditCardPaymentStrategy implements PaymentStrategy {
            public void processPayment(double amount) { /* process credit card payment */ }
        }
        class MomoPaymentStrategy implements PaymentStrategy { // Extend by adding a new class
            public void processPayment(double amount) { /* process Momo payment */ }
        }
        class PaymentProcessor {
            public void executePayment(PaymentStrategy strategy, double amount) {
                // PaymentProcessor does not need to be modified for new strategies
                strategy.processPayment(amount);
            }
        }
        
      • Benefits: Reduces the risk of breaking old code, keeps the system stable and makes it easier to add new things.
    • L – Liskov Substitution Principle:
      • "It is possible to use a subclass object to replace a parent class object and the program will still run correctly."
      • Meaning: If a piece of code works with a parent class, it must also work correctly with all of its children. The children must keep what the parent class promises, without causing errors or strange behavior.
      • For example:

        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 getArea() { return width * height; }
        }
        // Is Square really a Rectangle in all behaviors?
        class Square extends Rectangle {
            @Override
            public void setWidth(int side) {
                super.setWidth(side);
                super.setHeight(side); // Side effect: changing width also changes height
            }
            @Override
            public void setHeight(int side) {
                super.setHeight(side);
                super.setWidth(side); // Side effect: changing height also changes width
            }
        }
        // Client code that might break
        class AreaCalculator {
            public void printArea(Rectangle r) {
                r.setWidth(5);
                r.setHeight(4);
                // Expected area for a rectangle is 5*4=20
                // If r is a Square:
                // r.setWidth(5) makes width=5, height=5.
                // r.setHeight(4) makes height=4, width=4.
                // Area will be 4*4=16. This violates LSP as behavior changes.
                System.out.println("Calculated Area: " + r.getArea());
            }
        }
        

        This shows that Square above may not be a good replacement for Rectangle in this principle if the client expects independent behavior of setWidth and setHeight.

      • Benefits: Ensures that the program runs stably and correctly when you use inheritance.
    • I – Interface Segregation Principle:
      • "Clients should not be forced to depend on methods (in an interface) that they do not use."
      • Meaning: It is better to have many small interfaces, focusing on a specific set of functionality, rather than one large interface that contains many things. This helps classes to only care about what they really need to do.
      • For example:

        // "Fat" interface - not good
        interface GeneralWorker {
            void work();
            void eat();
            void sleep();
        }
        class HumanEmployee implements GeneralWorker { /* implements all three */ }
        class RobotEmployee implements GeneralWorker {
            public void work() { /* ... robots work */ }
            public void eat() { /* Robots don't eat! Forced to implement or throw exception */ }
            public void sleep() { /* Robots don't sleep biologically! */ }
        }
        // Better: Segregated interfaces
        interface Workable { void work(); }
        interface Eatable { void eat(); }
        interface Sleepable { void sleep(); }
        class HumanStaff implements Workable, Eatable, Sleepable { /* ... */ }
        class IndustrialRobot implements Workable { /* Robots only need to work */ }
        
      • Benefits: Reduces unnecessary dependencies, makes code clearer.
    • D – Dependency Inversion Principle:
      • "1. High-level modules should not depend on low-level modules. Both should depend on abstraction (interface).
      • "2. Abstraction should not depend on details. Details (concrete classes) should depend on abstraction."
      • Meaning: Helps reduce direct dependencies between parts of the program, making the system more flexible. Instead of part A calling part B directly, both A and B work through a common "interface".
      • For example:

        // Bad: Direct dependency on a concrete low-level module
        class LightBulb { // Low-level module (concrete implementation)
            public void turnOn() { System.out.println("LightBulb on"); }
            public void turnOff() { System.out.println("LightBulb off"); }
        }
        class ElectricSwitch { // High-level module
            private LightBulb bulb; // Directly depends on LightBulb
            public ElectricSwitch() { this.bulb = new LightBulb(); }
            public void press() {
                // Logic tied to LightBulb's specific methods
                if (/* bulb is off */ true) { // Simplified condition
                    bulb.turnOn();
                } else {
                    bulb.turnOff();
                }
            }
        }
        // Good: Depending on abstractions (interfaces)
        interface Switchable { // Abstraction
            void activate();
            void deactivate();
        }
        class ConcreteLightBulb implements Switchable { // Detail depends on abstraction
            public void activate() { System.out.println("ConcreteLightBulb on"); }
            public void deactivate() { System.out.println("ConcreteLightBulb off"); }
        }
        class ConcreteFan implements Switchable { // Another detail
            public void activate() { System.out.println("ConcreteFan spinning"); }
            public void deactivate() { System.out.println("ConcreteFan stopped"); }
        }
        class UniversalSwitch { // High-level module depends on abstraction
            private Switchable device;
            private boolean isOn = false;
            public UniversalSwitch(Switchable device) { // Dependency is injected
                this.device = device;
            }
            public void operate() {
                if (isOn) {
                    device.deactivate();
                    isOn = false;
                } else {
                    device.activate();
                    isOn = true;
                }
            }
        }
        
      • Benefits: More flexible system, easier to debug (dependencies can be replaced with mocks when testing), and easier to change components with less impact on each other.
  2. DRY (Don't Repeat Yourself): Don't Repeat Yourself
    • Meaning: A piece of code, logic or information should only be written once in one place in the entire system. Avoid writing the same thing over and over again in many places.
    • How to do it: Use functions, classes, or other ways to encapsulate and reuse common pieces of code.
    • If violated: When you need to fix a repeated logic, you have to look and fix it in all the places. This is easy to forget or miss, causing inconsistent errors and wasting time.
    • For example: If you have a complex email validation in 5 different screens, write it as a function isValidEmail(emailString) (this function will be in English in the actual code) and call this function from all 5 screens.
  3. KISS (Keep It Simple, Stupid): Keep Things As Simple As Possible!
    • Implication: Most systems run best if they are kept simple rather than made unnecessarily complex. Simplicity should be an important goal when designing.
    • Note: Simple does not mean sloppy or lacking functionality. The goal is to find the simplest solution to the problem that still meets the requirements .
    • When simple is best: When code is easier to read, understand, test, and fix, and still gets the job done. Avoid overly complex solutions to problems that are not actually complex.

Conclusion

Understanding and applying design principles such as SOLID, DRY, KISS will help you write better code, create more robust, flexible and maintainable software systems. Start practicing them in your projects, even small ones, and you will see clear progress. Good luck!

  • Share On: