Enhancing Software Design: A Real-World Journey from Code Smell to SOLID Principles
As software engineers, especially if you’re just starting, you’ll often hear about SOLID principles: five design guidelines that can make code easier to maintain, understand, and extend. In this article, we’ll walk through a code example inspired by a real-world codebase, showcasing how SOLID can help transform a piece of code from hard-to-maintain to a clean, flexible design.
Let's dive into the "before" and "after" to see the impact of each SOLID principle in action!
Before: The Legacy Code
Originally, our DocumentProcessor class handled various responsibilities—detecting, logging, and syncing document types—all within one function. As new document types were added, this design became more challenging to maintain.
At first glance, this code seems okay. It can handle different document types, syncing, and logging. But as we add more document types, it quickly becomes cluttered and hard to maintain. This design violates several SOLID principles, making it difficult to add new document types or change the behavior without modifying processDocument.
Analyzing the SOLID Violations
Let’s break down how each SOLID principle is violated and what that means for the code:
SRP (Single Responsibility Principle):
Violation: The processDocument function taking on multiple tasks directly within the DocumentProcessor class itself, rather than delegating them, while simply calling other functions, is still responsible for coordinating multiple actions—detecting, syncing, and logging—which bundles distinct responsibilities into one method.
This coordination should ideally be split so that each task (e.g., detecting document type, syncing, and logging) is handled independently.
For example, let's suppose we want to add a new document type, say HRDocument, with a unique logging behavior. In the legacy design, we would have to:
1- You modify processDocument to detect the HRDocument by adding a condition to handle this specific document type.
2- After detection, you add custom behavior for HRDocuments, like specific logging or syncing logic that would be unique to HR documents.
Every time you add a new document type, you are forced to modify multiple responsibilities within this one function. This approach becomes harder to maintain as more document types are added, especially when each document type needs specific behavior like logging or syncing.
OCP (Open-Closed Principle):
Violation: Each time a new document type (e.g., “HR document”) needs handling, we have to modify processDocument. This makes the code "closed" to extensions, forcing changes to existing logic whenever new document requirements arise.
LSP (Liskov Substitution Principle):
Violation: We lack the flexibility to substitute different logging behaviors for different document types, such as unique logging for finance versus legal documents. This forces us to modify processDocument to introduce specialized logging, undermining the ability to handle document types interchangeably.
ISP (Interface Segregation Principle):
Violation: The document-handling responsibilities—detection, protection, syncing, and logging—are all managed within DocumentProcessor, rather than being separated into more focused interfaces. This grouping makes DocumentProcessor depends on methods it may not always need, complicating its role.
DIP (Dependency Inversion Principle):
Violation: DocumentProcessor relies directly on methods like syncWithCloud, rather than using interfaces like ICloudSyncService. This dependence on concrete implementations limits flexibility and makes testing harder, as swapping services would require modifying the code.
After: Refactored Code with SOLID Principles
Let’s refactor this code to follow SOLID principles. Here’s a version that uses interfaces and separates each responsibility into its class. Each task—such as detecting the document type, syncing with the cloud, and logging—is now handled independently.
Interfaces and Base Classes:
DocumentProcessor and Usage Example:
Explanation of the SOLID Refactoring
SRP (Single Responsibility Principle)
In the refactored code, each class and interface now handles a single, specific task. For instance, document type matching is handled by IDocumentClassifier and ICloudSyncService is solely responsible for syncing documents, while DocumentType subclasses handle document-specific logging actions.
This design shift aligns each responsibility to its own component, reducing complexity in processDocument and making the code easier to extend or modify for specific document behaviors without needing to change the core processing logic.
For example, let's suppose we want to add a new document type, say HRDocument, with a unique logging behavior. In the refactored design, you simply:
1- Create a new subclass called HRDocument.
2- Implement a logDocumentAction() method in HRDocument for custom logging behavior.
Since each document type (like FinanceDocument, LegalDocument, or the new HRDocument) handles its own logging, you don’t have to touch the processDocument function in DocumentProcessor at all. processDocument will call logDocumentAction() on whatever document type is passed, and each document type will handle its own logging behavior.
OCP (Open-Closed Principle)
With this design, we can easily introduce new document types—such as FinanceDocument or LegalDocument—by creating new subclasses. This change doesn’t require modifications to processDocument, allowing the core processing logic to stay the same even as new document types are added.
LSP (Liskov Substitution Principle)
Previously, logging was hardcoded within the main processing logic, which limited flexibility for document-specific behavior. Now, with specialized subclasses like FinanceDocument and LegalDocument, each document type can implement its version of logDocumentAction. This lets us use different document types interchangeably without altering DocumentProcessor, ensuring that behavior is consistent for each document type without additional modification.
ISP (Interface Segregation Principle)
Each interface—such as IDocumentClassifier and ICloudSyncService—now focuses on a single task. This means DocumentProcessor only interacts with the specific functionalities it requires (like classification and syncing), making it less dependent on unrelated methods and keeping each task focused.
DIP (Dependency Inversion Principle)
By depending on abstractions (IDocumentClassifier and ICloudSyncService) rather than concrete implementations, the DocumentProcessor can work with any compatible implementation of these interfaces. This makes it easy to replace, extend, or mock dependencies like syncing and classification, allowing for greater flexibility and easier testing.
Acknowledgment
A special thanks to my friend and colleague, Ibrahim El-geddawy for reviewing this article. Together, while working on a high-impact U.S. project, we encountered real-world challenges that highlighted the importance of SOLID principles. This experience inspired us to share a practical example to make these concepts clearer and more applicable for new engineers.
Conclusion
By applying SOLID principles, we’ve improved the DocumentProcessor design, making it flexible, easy to extend, and simpler to maintain. For junior developers, this example shows that SOLID isn’t just theoretical—applying these principles makes a real difference in how manageable and adaptable your code becomes.
Following SOLID is a great way to grow your skills and write professional-grade code. Try refactoring a small part of your codebase using these principles—you’ll notice the difference!
Mobile Software Engineer
2 个月It's interesting how ISP and LSP were violated in the Legacy code. They're not like the usual examples you see in SOLID articles: ISP violation examples usually have an interface with multiple methods that are not always needed together, and the solution is to break it down into multiple interfaces. Here we didn't have interfaces to begin with, but we had unrelated methods (detectDocument & syncWithCloud) grouped together as if the class is implementing an "implied" interface that has these unrelated methods together, violating ISP. LSP violation examples usually have a super-class and a sub-class that overrides some methods in a way that makes a sub-class instance behavior not always valid in places where a super-class instance is expected. Here (in the legacy code example) we can't even substitute the logging behavior cause of how the logging method implementation is part of the same DocumentProcesser class. I would have considered these respectively as SRP & OCP violations, but it's very interesting to see the other "consequent" violations in them as well. Thanks for sharing :)
iOS Engineer@Robusta Studio | ITI graduate
3 个月???? ??? ?? ??????? ???? ????? ?? ????? ?? ?????? ?????? ??? ??????? ???? ?? ?? ??????
Software engineer | Problem Solving Mentor | back-end .Net
3 个月Ali Eid Sharawe Mohamed
Guest service agent at JW MARRIOTT Hotel doha
4 个月Great advice thank you ??
Software Engineer | Smart Contract Engineer | Backend Engineer | Blockchain Developer
4 个月As a side tip, try removing the "er/or" suffix from your mental model while naming classes. Having "ProcessedDocument" instead of "DocumentProcessor" will alert you before you exhaust the class with many responsibilities.