Software Engineering Principles
In software engineering, principles serve as foundational guidelines that help engineers make informed decisions throughout the software development lifecycle. These principles are not rigid rules but rather general best practices that, when applied, contribute to the creation of robust, efficient, and maintainable software systems. They provide a framework for addressing common challenges such as complexity, change, scalability, and collaboration in software projects.
The purpose of software engineering principles is to ensure that software is developed in a way that is predictable, reliable, and adaptable to evolving requirements. Adhering to these principles helps to reduce the likelihood of errors, improve team productivity, and create software that is easier to maintain and extend over time. They underpin many other rules and approaches to engineering high-quality code, and you will see them referred to many times in these notes.
Why Principles Matter
-
Guiding Decision-Making: Principles help engineers navigate the complexity of software systems by offering strategies to make trade-offs between conflicting requirements, such as performance versus scalability or flexibility versus simplicity.
-
Improving Software Quality: By following well-established principles, software engineers can create higher-quality software that is less prone to bugs, easier to test, and more aligned with user needs.
-
Supporting Collaboration: In team environments, shared principles provide a common language and approach to problem-solving, making it easier for developers to work together and understand each other’s code.
-
Facilitating Long-Term Maintenance: Principles guide the design of systems that can accommodate changes over time, ensuring that the software remains maintainable and scalable as new requirements emerge.
-
Encouraging Efficiency: By adhering to best practices, engineers can streamline the development process, reduce redundancy, and avoid reinventing the wheel, ultimately leading to faster and more cost-effective software delivery.
Software engineering principles are not one-size-fits-all solutions; their application often depends on the context of the project, the goals of the development team, and the specific challenges being faced. Understanding when and how to apply these principles is a key skill for any software engineer, as it involves balancing various factors such as time constraints, technical limitations, and user expectations.
SOLID
The SOLID principles are a set of five design guidelines that aim to make software systems more maintainable, scalable, and flexible. These principles promote clean and modular code by encouraging better organisation and reducing dependencies between components. By following SOLID principles, developers can create systems that are easier to understand, extend, and modify, while also minimising the risk of introducing bugs when making changes. These principles are widely recognised as foundational concepts in object-oriented software design and are key to building robust software architectures.
Single Responsibility Principle
The Single Responsibility Principle (SRP) states that a class should have only one reason to change, meaning that a class should have only one job or responsibility. Adhering to SRP helps to keep code more maintainable, readable, and easier to modify without introducing errors.
Key Concepts:
- One Responsibility: A class should focus on only one functionality or concern.
- Reason to Change: If a class is responsible for multiple things, a change in one responsibility could affect the other, leading to fragile code.
- Separation of Concerns: By splitting responsibilities into separate classes, you reduce complexity and make each part of the code easier to test, understand, and modify.
SRP is important because it greatly enhances the maintainability of your code. When each class is focused on only one responsibility, the code becomes easier to understand and modify. This means that changes or updates to a specific functionality won’t unintentionally affect other parts of the system. SRP also improves the testability of your code. By isolating responsibilities into separate classes, each class has fewer dependencies, making it easier to test specific functionalities without complex setups. Furthermore, SRP promotes reusability. Classes that have a single responsibility are often more self-contained and can be reused in different contexts without modification. As a result, your code becomes more modular, flexible, and easier to extend.
Example: Problem and Solution Using SRP
Problem: A Class with Multiple Responsibilities
Let’s take an example of an Invoice
class that handles both invoice generation and
sending emails. This class violates SRP because it is responsible for two things:
generating the invoice and emailing it.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Invoice
{
public void GenerateInvoice(Order order)
{
// Code to generate invoice
Console.WriteLine("Invoice generated for order.");
}
public void SendEmail(Order order)
{
// Code to send email to customer
Console.WriteLine("Email sent to customer for order.");
}
}
Issues with this Design:
- Multiple Responsibilities: The
Invoice
class is doing two things: generating invoices and sending emails. These are separate concerns and should be handled independently. - Maintenance Problem: If you need to change the way emails are sent (e.g., adding
logging or changing the email provider), you need to modify the
Invoice
class, even though the invoice generation logic hasn’t changed. This makes the code fragile. - Testing Complication: Testing this class would involve setting up both invoice generation and email sending, even if you’re only interested in testing one of those behaviours.
Solution: Refactor Using SRP
By applying the SRP, we can separate the concerns. We will create two separate classes: one for generating the invoice and another for sending the email.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Class responsible for generating the invoice
public class InvoiceGenerator
{
public void GenerateInvoice(Order order)
{
// Code to generate invoice
Console.WriteLine("Invoice generated for order.");
}
}
// Class responsible for sending the email
public class EmailSender
{
public void SendEmail(Order order)
{
// Code to send email
Console.WriteLine("Email sent to customer for order.");
}
}
Now, when you need to generate an invoice and send an email, you can do so by calling both classes:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class InvoiceService
{
private InvoiceGenerator invoiceGenerator;
private EmailSender emailSender;
public InvoiceService()
{
invoiceGenerator = new InvoiceGenerator();
emailSender = new EmailSender();
}
public void ProcessInvoice(Order order)
{
invoiceGenerator.GenerateInvoice(order);
emailSender.SendEmail(order);
}
}
Benefits of the Refactored Code:
- Separation of Concerns: The
InvoiceGenerator
class is responsible only for invoice generation, and theEmailSender
class is responsible only for sending emails. Each class has a single responsibility. - Easier Maintenance: Changes to how invoices are generated or how emails are sent will only affect their respective classes. You can modify one without risking changes in the other.
- Improved Testability: Testing these classes is now simpler because you can write unit tests for each responsibility independently. For example, you can test the InvoiceGenerator without worrying about email functionality.
Key Takeaways
SRP simplifies the design of your system by keeping each class focused on a single task or responsibility.
By separating concerns, you reduce the likelihood of introducing bugs when making changes and increase the modularity of your code.
Adhering to SRP leads to cleaner, more maintainable, and testable code.
The Open/Closed Principle
The Open/Closed Principle (OCP) states that software entities (classes, modules, functions, etc.) should be open for extension but closed for modification. In other words, you should be able to add new functionality to a class or module without changing its existing code. This principle encourages writing code that can adapt to new requirements or features without altering the already tested and functional parts of the system.
Key Concepts:
- Open for Extension: You should be able to extend the behaviour of a class or module to add new functionality.
- Closed for Modification: Once a class or module is implemented and tested, you should not modify its existing code to prevent introducing new bugs or breaking existing functionality.
- Avoid Breaking Changes: By adhering to OCP, you minimise the risk of breaking existing code when adding new features, leading to more stable and maintainable software.
OCP is important because it enhances the maintainability and flexibility of a software system. By ensuring that software entities are open for extension but closed for modification, developers can add new features or functionality without altering the existing, stable code. This approach minimises the risk of introducing bugs or breaking existing behaviour, which is crucial in larger systems where changes can have widespread impacts. OCP also promotes scalability, allowing the system to grow by adding new modules or classes instead of rewriting or modifying existing ones. Ultimately, OCP leads to a more modular and adaptable codebase that can evolve over time while maintaining stability and reducing development risks.
Example: Problem and Solution Using OCP
Problem: A Class that Violates OCP
Let’s say you have a InvoicePrinter
class that prints invoices for customers in plain
text. Later, a new requirement comes in to add support for printing invoices in PDF
format. Without following OCP, you might be tempted to modify the InvoicePrinter
class
directly to accommodate the new format, which would violate OCP because the class
would now need to be modified every time a new format is added.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class InvoicePrinter
{
public void PrintInvoice(Order order, string format)
{
if (format == "TEXT")
{
// Logic to print the invoice in text format
Console.WriteLine("Printing invoice in text format...");
}
else if (format == "PDF")
{
// Logic to print the invoice in PDF format
Console.WriteLine("Printing invoice in PDF format...");
}
}
}
Issues with this Design:
- Violation of OCP: The
InvoicePrinter
class is not closed for modification because every time a new format (e.g., HTML, XML) is added, you have to modify thePrintInvoice
method. - Fragility: Modifying the existing method increases the risk of introducing bugs or breaking functionality for previously supported formats.
- Unscalable: As the number of supported formats grows, the method will become increasingly complex and harder to maintain.
Solution: Refactor Using OCP
To apply the OCP, we can refactor the code by introducing an interface that defines a method for printing invoices, and then create separate classes for each specific format. This way, when a new format is needed, you can extend the behaviour by adding a new class without modifying the existing ones.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Define an interface for printing invoices
public interface IInvoicePrinter
{
void PrintInvoice(Order order);
}
// Class for printing in text format
public class TextInvoicePrinter : IInvoicePrinter
{
public void PrintInvoice(Order order)
{
Console.WriteLine("Printing invoice in text format...");
}
}
// Class for printing in PDF format
public class PdfInvoicePrinter : IInvoicePrinter
{
public void PrintInvoice(Order order)
{
Console.WriteLine("Printing invoice in PDF format...");
}
}
Now, you can easily add a new format by creating a new class that implements the IInvoicePrinter interface without touching the existing code:
1
2
3
4
5
6
7
8
// Example of adding a new format without modifying existing code
public class HtmlInvoicePrinter : IInvoicePrinter
{
public void PrintInvoice(Order order)
{
Console.WriteLine("Printing invoice in HTML format...");
}
}
To use this setup, you can manage the specific printer implementation through dependency injection or by selecting the appropriate printer at runtime:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class InvoiceService
{
private IInvoicePrinter invoicePrinter;
public InvoiceService(IInvoicePrinter printer)
{
this.invoicePrinter = printer;
}
public void ProcessInvoice(Order order)
{
invoicePrinter.PrintInvoice(order);
}
}
Benefits of the Refactored Code:
- Open for Extension: New formats can be added by creating new classes (e.g.,
HtmlInvoicePrinter
) that implement theIInvoicePrinter
interface. This allows you to extend functionality without modifying the existing code. - Closed for Modification: The
InvoicePrinter
class itself is never modified. Existing functionality remains stable, reducing the risk of introducing bugs or regressions. - Better Scalability: The system can easily accommodate future requirements (e.g., XML, CSV formats) by simply adding new printer classes without changing the core logic.
- Cleaner Code: The code is more modular, easier to test, and more aligned with good design practices.
Key Takeaways:
OCP encourages you to write code that can be easily extended but not modified. This leads to better maintainability and stability in software projects.
OCP helps you add new features or behaviours (such as new formats for invoice printing) without altering the tested and stable parts of your system.
By using abstractions (like interfaces or abstract classes), you can keep your core logic intact while still allowing flexibility for new functionality.
Following OCP not only improves the modularity and scalability of your system but also reduces the chances of introducing errors when adding new features. It ensures that your codebase remains stable as it evolves.
Liskov Substitution Principle
The Liskov Substitution Principle (LSP) states that objects of a superclass should be replaceable with objects of a subclass without affecting the correctness of the program. In simpler terms, if class B is a subclass of class A, you should be able to replace A with B without changing the expected behaviour of the program.
This principle helps maintain polymorphism while ensuring that inheritance is used correctly, preserving the expected functionality of the base class when extending it. Violating LSP leads to unexpected behaviour and breaks the substitutability of objects, reducing the flexibility and reliability of the system.
Key Concepts:
- Substitutability: Subclasses should be able to stand in for their base classes without affecting the correctness of the application.
- behavioural Integrity: Subclasses should not override or alter base class methods in a way that violates the expected behaviour.
- Avoid Breaking Contracts: Subclasses must honor the “contract” established by the base class, meaning they should not weaken postconditions or strengthen preconditions of the base class methods.
LSP is crucial because it ensures that a program remains correct and reliable when subclasses are used in place of their base classes. Adhering to LSP allows developers to extend functionality through inheritance without breaking the existing behaviour of the system. It maintains the integrity of polymorphism by ensuring that objects of a subclass can seamlessly replace those of the superclass, without causing unexpected outcomes or requiring additional checks. This principle promotes flexibility, enabling code reuse while reducing the risk of introducing bugs. By following LSP, you create more maintainable, modular, and robust systems that can evolve over time without compromising reliability or correctness.
Example: Problem and Solution Using LSP
Problem: A Class that Violates LSP
Let’s say we have a base class Bird
with a method Fly()
. We then create a subclass
Penguin
, but penguins can’t fly. This introduces a violation of the Liskov Substitution
Principle because substituting a Penguin
for a Bird
breaks the expected behaviour defined
by the base class.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Bird
{
public virtual void Fly()
{
Console.WriteLine("Flying...");
}
}
public class Penguin : Bird
{
public override void Fly()
{
throw new InvalidOperationException("Penguins can't fly!");
}
}
Issues with this Design:
- Violation of LSP: The
Penguin
class cannot fulfill the expectations set by theBird
class becausePenguin.Fly()
throws an exception, which breaks the contract that all birds should be able to fly. - Unreliable Substitution: Any code that expects a Bird object to fly will fail when
it encounters a
Penguin
. This violates the expectation of substitutability. - Fragile Code: Developers who use the Bird class must now include checks to handle the case where a bird cannot fly, making the code more complex and error-prone.
Solution: Refactor Using LSP
To adhere to LSP, we need to avoid forcing all birds to implement the Fly()
method.
Instead, we can refactor the design by introducing a more appropriate abstraction. We
can create a separate IFlyable
interface that is implemented by birds that can fly, and
remove the Fly()
method from the base Bird
class. This way, we separate the flying
behaviour from the general bird behaviour, ensuring that subclasses only implement behaviours
that apply to them.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Bird
{
public string Name { get; set; }
public void Eat()
{
Console.WriteLine($"{Name} is eating.");
}
}
public interface IFlyable
{
void Fly();
}
public class Sparrow : Bird, IFlyable
{
public void Fly()
{
Console.WriteLine($"{Name} is flying.");
}
}
public class Penguin : Bird
{
// Penguins don't implement IFlyable because they can't fly
}
Benefits of the Refactored Code:
- Substitutability Preserved: Now, both
Sparrow
andPenguin
can be substituted forBird
without violating LSP, since there is no assumption that all birds can fly. - Clear Separation of Concerns: The ability to fly is now handled by the
IFlyable
interface, meaning only birds that can fly will implement it. This ensures thatPenguin
is not forced to provide an invalid implementation for flying. - More Flexible Design: Other animals that can fly (like bats or insects) can also
implement the
IFlyable
interface, increasing the flexibility of the design without changing the core behaviour of theBird
class. - Simplified Code: The code now makes it clear which animals can fly and which cannot, reducing the need for conditionals or error-prone workarounds.
Key Takeaways:
LSP ensures that subclasses can replace their base classes without introducing unexpected behaviour, maintaining the correctness of the program.
When using inheritance, avoid overriding or altering methods in a way that violates the expectations set by the base class. If a subclass cannot properly implement a method, it should not inherit that method.
Adhering to LSP promotes modular, reliable, and flexible code that leverages inheritance and polymorphism correctly. It helps prevent fragile code that requires constant checking or handling of special cases, leading to a cleaner and more robust design.
Interface Segregation Principle
The Interface Segregation Principle (ISP) states that clients should not be forced to depend on interfaces they do not use. In other words, an interface should provide only the methods that are relevant to the specific functionality required by the client. If an interface is too large or has methods that are irrelevant to certain clients, it violates ISP and leads to a less flexible design.
The goal of ISP is to create more modular and focused interfaces, allowing clients to only implement the functionality they need, which improves maintainability and reduces unnecessary dependencies.
Key Concepts:
- Cohesive Interfaces: Interfaces should be small and focused, containing only the methods that are relevant to the specific tasks of the client.
- Avoid “Fat” Interfaces: Large, multipurpose interfaces that require clients to implement methods they don’t need should be avoided.
- Decoupling: By separating interfaces based on functionality, clients are decoupled from unnecessary methods and dependencies, leading to more maintainable and adaptable code.
ISP is important because it enhances flexibility, maintainability, and modularity in software design. By ensuring that clients only depend on interfaces that provide the functionality they actually need, ISP helps avoid the problems caused by large, “fat” interfaces that bundle together unrelated methods. When an interface includes unnecessary methods, clients are forced to implement or account for functionality they don’t use, leading to increased complexity and tighter coupling. ISP ensures that interfaces are more cohesive and focused, making it easier to modify and extend the system without affecting unrelated components. This results in cleaner, more adaptable code, where changes to one part of the system don’t unnecessarily ripple through other areas. Ultimately, ISP promotes better decoupling, enabling systems to evolve more smoothly while maintaining their stability and reliability.
Example: Problem and Solution Using ISP
Problem: A “Fat” Interface that Violates ISP
Let’s consider an example where we have a Worker
interface that defines several methods
related to both manual and automated workers. This interface violates ISP because not
all workers (such as robots) will perform tasks like taking breaks, and manual workers
may not need automation methods.
1
2
3
4
5
6
7
public interface IWorker
{
void Work();
void TakeBreak();
void StartMaintenance();
void PerformAutomationTask();
}
We have two classes, HumanWorker
and RobotWorker
, both of which implement the
IWorker
interface:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class HumanWorker : IWorker
{
public void Work() { Console.WriteLine("Human is working."); }
public void TakeBreak() { Console.WriteLine("Human is taking a break."); }
public void StartMaintenance() { Console.WriteLine("Human does not need maintenance."); }
public void PerformAutomationTask() { Console.WriteLine("Human cannot perform automation tasks."); }
}
public class RobotWorker : IWorker
{
public void Work() { Console.WriteLine("Robot is working."); }
public void TakeBreak() { throw new NotImplementedException(); }
public void StartMaintenance() { Console.WriteLine("Robot is undergoing maintenance."); }
public void PerformAutomationTask() { Console.WriteLine("Robot is performing automation tasks."); }
}
Issues with this Design:
- Violation of ISP: Both
HumanWorker
andRobotWorker
are forced to implement methods they don’t need, likePerformAutomationTask
forHumanWorker
andTakeBreak
forRobotWorker
. This leads to methods that throw exceptions or are irrelevant. - Unnecessary Coupling: This interface couples unrelated functionality into a single “fat” interface, making it harder to extend or modify without affecting all clients.
- Reduced Flexibility: Any changes to the interface (like adding new methods) will affect both types of workers, even if the changes are only relevant to one.
Solution: Refactor Using ISP
To fix this, we can refactor the code by splitting the IWorker
interface into smaller,
more focused interfaces that each represent a specific set of responsibilities. Each
worker will implement only the interfaces that are relevant to its functionality.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Interface for general work-related tasks
public interface IWorker
{
void Work();
}
// Interface for workers that take breaks
public interface IRestable
{
void TakeBreak();
}
// Interface for workers that require maintenance
public interface IMaintainable
{
void StartMaintenance();
}
// Interface for workers that perform automation tasks
public interface IAutomationWorker
{
void PerformAutomationTask();
}
Now, we can implement these smaller interfaces in the appropriate classes:
1
2
3
4
5
6
7
8
9
10
11
12
public class HumanWorker : IWorker, IRestable
{
public void Work() { Console.WriteLine("Human is working."); }
public void TakeBreak() { Console.WriteLine("Human is taking a break."); }
}
public class RobotWorker : IWorker, IMaintainable, IAutomationWorker
{
public void Work() { Console.WriteLine("Robot is working."); }
public void StartMaintenance() { Console.WriteLine("Robot is undergoing maintenance."); }
public void PerformAutomationTask() { Console.WriteLine("Robot is performing automation tasks."); }
}
Benefits of the Refactored Code:
- Interfaces are Focused: Each interface now only defines methods that are relevant
to the specific functionality.
HumanWorker
doesn’t need to worry about automation, andRobotWorker
no longer needs to implementTakeBreak()
. - Decoupling: The responsibilities are now properly separated. Each worker type implements only the interfaces that are relevant to them, decoupling unrelated functionality.
- Improved Flexibility: Changes to one interface (e.g., adding more automation-related methods) will not affect unrelated workers, like human workers who don’t deal with automation.
Key Takeaways
ISP ensures that clients are not forced to depend on interfaces they don’t use, reducing unnecessary coupling and dependencies.
By splitting large, “fat” interfaces into smaller, more focused ones, you allow each client to implement only what they need, making the code more flexible and easier to maintain.
ISP promotes modularity and decoupling, allowing the system to evolve more easily without impacting unrelated parts of the codebase.
Dependency Inversion Principle
The Dependency Inversion Principle (DIP) states that high-level modules should not depend on low-level modules; both should depend on abstractions. Additionally, abstractions should not depend on details; details should depend on abstractions.
The goal of DIP is to reduce the coupling between high-level and low-level components in a system, making the software more flexible, maintainable, and testable. By depending on abstractions (interfaces or abstract classes) rather than concrete implementations, the system becomes easier to extend and modify without breaking existing functionality.
Key Concepts:
- Decoupling: High-level modules should depend on abstract interfaces, not concrete implementations, so they are not tightly coupled to low-level details.
- Abstractions First: The overall design should prioritise abstractions (such as interfaces) that allow for the flexible swapping of implementations without changing the high-level logic.
- Maintainability: By inverting dependencies, you make the system more maintainable and scalable, as you can change implementations or details without impacting the higher-level business logic.
DIP is crucial because it reduces the tight coupling between high-level modules and low-level implementations, making the system more flexible, maintainable, and adaptable to change. By depending on abstractions instead of concrete classes, high-level modules can function independently of specific details, allowing low-level components to be swapped or modified without affecting the core business logic. This decoupling promotes better scalability and simplifies testing, as mock or alternative implementations can be easily introduced. Ultimately, DIP enhances the overall structure of the software, ensuring that it remains stable and easier to manage as the system evolves over time.
Example: Problem and Solution Using DIP
Problem: Tight Coupling Violates DIP
Consider a scenario where a UserService
class directly depends on a MySQLDatabase
class to store user data. This design violates DIP because the high-level UserService
is directly dependent on the low-level MySQLDatabase
implementation. This tight
coupling makes it difficult to switch to a different database (e.g., MongoDB) or test
the UserService
in isolation.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class MySQLDatabase
{
public void SaveUser(string username)
{
Console.WriteLine("Saving user to MySQL database");
}
}
public class UserService
{
private MySQLDatabase database;
public UserService()
{
database = new MySQLDatabase(); // High-level module depends on low-level implementation
}
public void RegisterUser(string username)
{
database.SaveUser(username);
Console.WriteLine("User registered successfully");
}
}
Issues with this Design:
- Tight Coupling:
UserService
is tightly coupled toMySQLDatabase
. Any changes in how data is stored (e.g., switching to MongoDB or a file-based system) would require modifyingUserService
, violating DIP. - Poor Testability: Testing
UserService
in isolation is difficult because you cannot easily replaceMySQLDatabase
with a mock or stub. - Lack of Flexibility: The system is inflexible, as changing the storage mechanism
requires changes to both the
UserService
and theMySQLDatabase
.
Solution: Refactor Using DIP
To adhere to the Dependency Inversion Principle, we can introduce an abstraction in the
form of an IDatabase
interface. Both MySQLDatabase
and other storage mechanisms,
such as MongoDBDatabase
, can implement this interface. The UserService
will depend
on the IDatabase
abstraction rather than any concrete implementation. This allows us
to switch between different databases or mock the database in tests without modifying
the UserService
.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// Define an abstraction for database operations
public interface IDatabase
{
void SaveUser(string username);
}
// Concrete implementation for MySQL database
public class MySQLDatabase : IDatabase
{
public void SaveUser(string username)
{
Console.WriteLine("Saving user to MySQL database");
}
}
// Concrete implementation for MongoDB database
public class MongoDBDatabase : IDatabase
{
public void SaveUser(string username)
{
Console.WriteLine("Saving user to MongoDB database");
}
}
// High-level UserService depends on abstraction (IDatabase)
public class UserService
{
private IDatabase database;
// Dependency is injected through the constructor
public UserService(IDatabase database)
{
this.database = database;
}
public void RegisterUser(string username)
{
database.SaveUser(username);
Console.WriteLine("User registered successfully");
}
}
Now, the UserService
can depend on any database implementation that adheres to the
IDatabase
interface. If you want to switch to MongoDB, you just pass a
MongoDBDatabase
instance when creating the UserService
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Program
{
public static void Main(string[] args)
{
// Use MySQLDatabase
IDatabase mySqlDatabase = new MySQLDatabase();
UserService userService1 = new UserService(mySqlDatabase);
userService1.RegisterUser("John Doe");
// Use MongoDBDatabase
IDatabase mongoDbDatabase = new MongoDBDatabase();
UserService userService2 = new UserService(mongoDbDatabase);
userService2.RegisterUser("Jane Smith");
}
}
Benefits of the Refactored Code:
- Loosely Coupled: The
UserService
now depends on theIDatabase
abstraction rather than a concrete implementation, making it more flexible and maintainable. - Increased Flexibility: You can easily switch between different database
implementations (e.g., MySQL, MongoDB) without modifying the
UserService
code. - Better Testability: Testing becomes simpler because you can create mock
implementations of
IDatabase
for unit testing without relying on actual database connections. - Scalability: The system is more scalable, allowing for new storage mechanisms to
be added with minimal impact on the higher-level
UserService
.
Key Takeaways
The Dependency Inversion Principle (DIP) helps reduce tight coupling between high-level modules and low-level implementations by encouraging dependencies on abstractions rather than concrete classes.
By adhering to DIP, systems become more flexible, maintainable, and testable, as high-level components are insulated from changes to low-level details.
DIP is closely related to Dependency Injection, where abstractions (interfaces) are passed to high-level modules, allowing the concrete implementation to be determined outside the module itself.
Other Principles
Beyond the SOLID principles, other well-known rules provide foundational guidelines that help developers create high-quality, reliable, and scalable software systems. These principles aim to address common challenges such as managing complexity, ensuring maintainability, and improving collaboration in development teams. Examples include DRY (Don’t Repeat Yourself), which encourages the elimination of redundant code, and KISS (Keep It Simple, Stupid), which advocates for simple and straightforward solutions to problems. Additionally, the YAGNI (You Aren’t Gonna Need It) principle emphasises building only what is necessary, avoiding over-engineering and the Law of Demeter (LoD) which encourages loose coupling of classes. These principles collectively guide software engineers in making better design decisions, improving code readability, and maintaining software that can adapt to future needs.
DRY
The DRY (Don’t Repeat Yourself) principle is one of the most important guidelines in software engineering. It states that every piece of knowledge or logic should be represented in a system only once. Duplication of code or logic across a system leads to increased complexity, makes maintenance difficult, and introduces the risk of inconsistencies. DRY encourages developers to avoid repetition by centralising logic, reducing redundancy, and promoting code reuse.
Key Concepts of DRY:
- Avoid Code Duplication: Repeated logic or code should be abstracted or refactored into reusable components (such as methods, functions, or modules).
- Maintainability: Centralising repeated logic makes the system easier to maintain. When changes are necessary, you only need to update the code in one place.
- Consistency: DRY reduces the risk of errors that can occur when maintaining multiple, inconsistent copies of the same logic.
The DRY principle is important because it promotes efficiency, maintainability, and consistency in software development. By avoiding code duplication, developers can centralise logic, making it easier to update and maintain. When the same logic is repeated in multiple places, any necessary change requires updating each instance, increasing the risk of errors and inconsistencies. DRY ensures that changes only need to be made in one place, which reduces maintenance efforts and the likelihood of bugs. Additionally, DRY leads to cleaner, more readable code, as developers don’t have to sift through repeated blocks of code to understand the system. Ultimately, DRY helps create scalable, efficient, and reliable software.
Example: Problem and Solution Using DRY
Problem: Repetitive Code Violates DRY
Let’s say you have a program that calculates discounts for different types of customers, and the same logic is being duplicated for different methods. In this case, the discount calculation logic is repeated for both regular customers and premium customers.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class DiscountService
{
public double CalculateDiscountForRegularCustomer(double price)
{
// Repeated logic for discount calculation
double discount = price * 0.1;
double discountedPrice = price - discount;
return discountedPrice;
}
public double CalculateDiscountForPremiumCustomer(double price)
{
// Same logic repeated for premium customers
double discount = price * 0.2;
double discountedPrice = price - discount;
return discountedPrice;
}
}
Issues with this Design:
- Code Duplication: The discount calculation logic is repeated, violating the DRY principle. Any future change in the way discounts are calculated would need to be updated in both methods.
- Increased Maintenance: If the discounting rule changes, developers have to update multiple parts of the code, increasing the risk of missing one or making mistakes.
- Harder to Scale: Adding new types of customers with different discount rates would lead to even more duplication, compounding the maintenance problem.
Solution: Refactor Using DRY
To adhere to the DRY principle, we can refactor the code by extracting the common discount calculation logic into a single method. The customer type-specific logic can be passed as a parameter, making the code reusable and reducing duplication.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class DiscountService
{
// Centralised logic for calculating the discount
public double CalculateDiscount(double price, double discountRate)
{
double discount = price * discountRate;
double discountedPrice = price - discount;
return discountedPrice;
}
public double CalculateDiscountForRegularCustomer(double price)
{
return CalculateDiscount(price, 0.1); // Regular customers get 10% discount
}
public double CalculateDiscountForPremiumCustomer(double price)
{
return CalculateDiscount(price, 0.2); // Premium customers get 20% discount
}
}
Benefits of the Refactored Code:
- Code Reuse: The
CalculateDiscount
method handles the common discount logic, eliminating repetition. - Easier Maintenance: If the discount calculation method changes, you only need to
update the
CalculateDiscount
method, ensuring consistency across all customer types. - Scalability: Adding new customer types or changing discount rates is now simple, as you can just pass a different discount rate without duplicating the core logic.
Key Takeaways
The DRY principle helps prevent code duplication by encouraging the centralisation of repeated logic into reusable components such as methods, classes, or modules.
Duplication increases complexity, maintenance costs, and the likelihood of bugs, while DRY promotes more efficient development practices.
By refactoring code to be DRY, you make it easier to scale, maintain, and ensure that changes are reflected consistently across the codebase.
KISS
The KISS (Keep It Simple, Stupid) principle encourages developers to write code that is as simple and straightforward as possible. The idea behind KISS is that simplicity leads to better outcomes—simple code is easier to understand, maintain, and debug. Complexity, on the other hand, introduces unnecessary risks, makes code harder to maintain, and increases the likelihood of errors. The goal of KISS is to avoid over-complicating solutions, particularly when a simpler approach would suffice.
Key Concepts of KISS
- Simplicity: Aim to write the simplest possible solution that meets the requirements.
- Avoid Over-Engineering: Resist the temptation to add complexity, features, or abstractions that aren’t needed.
- Ease of Understanding: Code should be easy for others (and your future self) to read and understand.
- Maintainability: Simpler code is easier to maintain, troubleshoot, and extend over time.
The KISS principle is important because it emphasises the value of simplicity in software development. Simple code is easier to read, understand, and maintain, making it more accessible to other developers and your future self. When code is kept simple, it becomes easier to troubleshoot, debug, and extend, reducing the likelihood of introducing errors or breaking existing functionality. By avoiding unnecessary complexity and over-engineering, the KISS principle helps streamline development processes, saving time and resources. Ultimately, following KISS leads to more efficient development and results in software that is more flexible, maintainable, and stable.
Example: Problem and Solution Using KISS
Problem: Over-Engineered Code
Imagine you are tasked with calculating the sum of numbers in a list. A developer might over-engineer the solution by introducing unnecessary layers of abstraction, complicating a simple task.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Over-complicated solution with unnecessary abstraction
public interface ISumCalculator
{
int CalculateSum(List<int> numbers);
}
public class SumCalculator : ISumCalculator
{
public int CalculateSum(List<int> numbers)
{
int sum = 0;
foreach (int number in numbers)
{
sum += number;
}
return sum;
}
}
public class MainApp
{
public static void Main(string[] args)
{
ISumCalculator calculator = new SumCalculator();
List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
int result = calculator.CalculateSum(numbers);
Console.WriteLine("Sum: " + result);
}
}
Issues with this Design:
- Unnecessary Complexity: There is no need for an interface (
ISumCalculator
) or a class (SumCalculator
) to calculate a simple sum. These abstractions add complexity without adding real value. - Over-Engineering: The problem could be solved with a much simpler approach, yet the code introduces unnecessary layers that complicate a basic task.
- Harder to Maintain: If this solution is part of a larger system, other developers (or even the same developer in the future) will need to understand unnecessary abstractions, making the code harder to maintain.
Solution: Refactor Using KISS
To adhere to the KISS principle, we can eliminate the unnecessary abstractions and simplify the solution:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Simple, straightforward solution
public class MainApp
{
public static void Main(string[] args)
{
List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
int sum = CalculateSum(numbers);
Console.WriteLine("Sum: " + sum);
}
public static int CalculateSum(List<int> numbers)
{
int sum = 0;
foreach (int number in numbers)
{
sum += number;
}
return sum;
}
}
Benefits of the Refactored Code:
- Simplicity: The code now directly solves the problem without unnecessary abstraction. It is clear and easy to understand.
- Ease of Maintenance: Since the solution is straightforward, it will be much easier to maintain and modify in the future.
- Reduced Overhead: There is no need for interfaces or extra classes to calculate the sum, reducing both the cognitive and resource overhead.
Usage Example:
1
2
3
4
5
6
7
// Simplified usage and easier to maintain
public static void Main(string[] args)
{
List<int> numbers = new List<int> { 1, 2, 3, 4, 5 };
int sum = CalculateSum(numbers);
Console.WriteLine("Sum: " + sum);
}
This approach directly solves the problem with minimal code, following the KISS principle.
Key Takeaways
KISS encourages developers to keep solutions as simple as possible, avoiding unnecessary abstractions, complexity, or features that aren’t required.
Simpler code is more maintainable, easier to understand, and less prone to bugs.
Over-engineering wastes time, resources, and can make future modifications harder.
Always strive for the simplest solution that fully meets the requirements, but avoid oversimplification that compromises functionality or flexibility.
YAGNI
The YAGNI (You Aren’t Gonna Need It) suggests developers should only implement features when they are actually required, not in anticipation of future needs. The idea behind YAGNI is to avoid over-engineering or adding unnecessary complexity to the system by building features that may never be used. This principle encourages a minimalist approach, where the focus is on building only what is needed for the task at hand, leading to more efficient, maintainable, and understandable code.
Key Concepts of YAGNI
- Avoid Premature Development: Don’t implement features, logic, or functionality until they are explicitly needed.
- Simplicity: Build only what is required to meet current needs, keeping the system as simple as possible.
- Avoid Over-Engineering: Creating features “just in case” adds complexity and technical debt without providing immediate value.
- Iterative Development: Focus on incremental development, where features are added as needed, ensuring the system evolves based on real requirements.
The YAGNI principle is important because it helps developers avoid unnecessary complexity and over-engineering by focusing only on the features that are immediately required. By not building features in anticipation of future needs, developers can save time and resources, ensuring that the codebase remains simple and maintainable. Implementing features that may never be used adds complexity and technical debt, making the system harder to manage and increasing the potential for bugs. YAGNI encourages a more iterative development process, where features are added only when they are truly necessary, leading to more efficient and focused development. This results in a cleaner, more maintainable codebase that can evolve naturally as new requirements arise.
Example: Problem and Solution Using YAGNI
Problem: Over-Engineering Violates YAGNI
Consider a scenario where a developer is tasked with creating a system to handle user authentication for an application. The immediate requirement is to allow users to log in using a username and password. However, anticipating future requirements, the developer decides to implement support for OAuth (Google, Facebook login), multi-factor authentication, and even a potential feature for biometric login.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class AuthService
{
// Over-engineered: trying to support multiple authentication mechanisms before they are needed
public void Authenticate(string username, string password)
{
// Simple username/password authentication
Console.WriteLine("Authenticating with username and password.");
}
public void AuthenticateWithGoogle(string googleToken)
{
// Authentication using Google OAuth
Console.WriteLine("Authenticating with Google.");
}
public void AuthenticateWithFacebook(string facebookToken)
{
// Authentication using Facebook OAuth
Console.WriteLine("Authenticating with Facebook.");
}
public void AuthenticateWithBiometrics(string biometricData)
{
// Future feature for biometric login
Console.WriteLine("Authenticating with biometrics.");
}
}
Issues with this Design:
- Premature Development: The only requirement is for username and password authentication, but the developer has added support for other methods that are not currently needed.
- Unnecessary Complexity: The codebase becomes more complex with each additional feature, even though the extra functionality may never be used or could be implemented differently in the future.
- Wasted Resources: Time and effort are spent on developing and maintaining features that are not part of the immediate requirements.
- Maintenance Burden: Future developers will need to maintain and understand unnecessary code, which increases technical debt.
Solution: Refactor Using YAGNI
To follow the YAGNI principle, the developer should implement only the features that are needed right now: in this case, basic username and password authentication. Other authentication methods can be added later when (and if) they become necessary.
1
2
3
4
5
6
7
8
9
public class AuthService
{
// Only implement the required authentication logic for now
public void Authenticate(string username, string password)
{
// Simple username/password authentication
Console.WriteLine("Authenticating with username and password.");
}
}
Benefits of the Refactored Code:
- Simplicity: The code is now focused solely on the current requirement, making it easy to understand and maintain.
- Saves Time and Resources: By avoiding unnecessary features, the developer saves time and can focus on delivering the required functionality.
- Easier to Extend: When the need arises for OAuth or other authentication methods, those features can be added incrementally, based on the real requirements at that time.
- Improved Maintainability: Less code means fewer bugs, fewer dependencies, and a smaller maintenance burden.
Key Takeaways
The YAGNI principle encourages developers to avoid building features that are not immediately required, focusing on simplicity and preventing over-engineering.
Writing only what is needed minimises complexity, improves maintainability, and reduces the risk of technical debt.
Developers should focus on the current requirements and defer additional features until they are truly necessary, using an iterative development approach to extend the system as needed.
Law of Demeter
The Law of Demeter (LoD), also known as the “principle of least knowledge,” encourages minimising dependencies between different parts of a system. The primary objective of the Law of Demeter is to reduce coupling and improve the modularity of the code, making it more maintainable, flexible, and less prone to errors.
Key Concept of the Law of Demeter
An object should only interact with:
- Itself: its own methods and fields.
- Directly associated objects: objects passed in as parameters or objects it directly holds.
- Objects it creates: instances of objects it creates internally.
- Components of its direct association: objects returned by its own methods.
A violation of the Law of Demeter often leads to message chains (where one object calls a method on another, and that object calls a method on yet another object, etc.). This creates tight coupling between multiple classes, making the code fragile and hard to maintain.
Example Problem and Solution using the Law of Demeter
Problem: Accessing the internal structure of another object
Consider a scenario in which an online retailer needs to send goods to customers. The
classes that represent customers, their addresses and orders are quite simple and very
commonly used. Part of the process requires the printing of a shipping label which might
mistakenly be imagined as part of the Order
class:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class Address
{
public string Street { get; set; }
public string City { get; set; }
}
public class Customer
{
public Address Address { get; set; }
public Customer(Address address)
{
Address = address;
}
}
public class Order
{
public Customer Customer { get; set; }
public Order(Customer customer)
{
Customer = customer;
}
public void PrintShippingLabel()
{
// Violates the Law of Demeter: Long method chain accessing internal objects
Console.WriteLine("Shipping to: " + Customer.Address.Street + ", " + Customer.Address.City);
}
}
Issues with this design
In the PrintShippingLabel
method, the Order
class directly accesses the Address
object through the Customer
object, creating a message chain (Customer.Address.Street
).
This violates the Law of Demeter because the Order
class should not “know” the internal
structure of the Customer
and Address
classes. This makes the code tightly coupled—any
changes in the structure of Customer
or Address
would force changes in Order
,
reducing maintainability and flexibility.
Solution Using the Law of Demeter
To adhere to the Law of Demeter, the Order
class should only interact directly with the
Customer
class. Instead of accessing the Address
through the Customer
, we can move
the logic for retrieving the shipping address into the Customer
class, thus reducing
the dependency on its internal structure.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class Address
{
public string Street { get; set; }
public string City { get; set; }
}
public class Customer
{
public Address Address { get; set; }
public Customer(Address address)
{
Address = address;
}
// Introduce a method that provides the necessary information directly
public string GetShippingAddress()
{
return Address.Street + ", " + Address.City;
}
}
public class Order
{
public Customer Customer { get; set; }
public Order(Customer customer)
{
Customer = customer;
}
public void PrintShippingLabel()
{
// Now the Order class only interacts with the Customer class directly
Console.WriteLine("Shipping to: " + Customer.GetShippingAddress());
}
}
Benefits of the Refactored Code
- Reduced Coupling: Now, the
Order
class interacts directly with theCustomer
class through a clear, well-defined interface (GetShippingAddress
). TheOrder
class no longer depends on the internal structure ofCustomer
orAddress
, making it easier to change these classes without breaking the code. - Encapsulation: The details of the
Address
class are encapsulated withinCustomer
. TheOrder
class doesn’t need to know about the internal structure ofAddress
; it just asksCustomer
for the shipping address. - Maintainability: If the way the address is formatted changes (e.g., adding a country
or postal code), the change is localised within the
Customer
class. TheOrder
class remains unaffected.
Key Takeaways
By following the Law of Demeter, objects remain more modular, reducing their reliance on the internal structure of other objects.
Code that adheres to the Law of Demeter is easier to maintain because fewer parts of the system are tightly coupled. Changes can be made in one class without affecting unrelated parts of the system.
The fewer assumptions one class makes about another’s internal structure, the more resilient the system is to change.
It reinforces encapsulation by ensuring that objects do not expose their internal details unnecessarily to other objects.
Conclusion
Principles in software engineering are essential for guiding engineers toward creating software that is well-structured, scalable, and maintainable. They help in managing complexity, improving collaboration, and ensuring that the software can adapt to change while remaining reliable and efficient over time.
To find out about other common principles, please visit the DevIQ website.