10. SOLID Principles and Design PatternsIntroduction and ContextObject-oriented programming becomes difficult not when classes are first introduced, but when a system contains hundreds of classes that evolve under changing requirements. A design may compile correctly and still be difficult to test, extend, reuse, or understand. SOLID principles and design patterns provide two complementary forms of guidance. SOLID offers principles for evaluating the responsibilities and dependencies of classes, while design patterns provide recurring structural or behavioral solutions to common design problems. These ideas are not substitutes for analysis. A pattern applied mechanically can produce unnecessary abstraction, excessive indirection, or a system that is harder rather than easier to maintain. At postgraduate level, the important skill is therefore judgment: identifying the source of change, controlling coupling, selecting an appropriate abstraction, and resisting overengineering. The examples in this chapter use modern C++ concepts such as interfaces represented by abstract classes, smart pointers, lambdas, and standard library facilities. The principles, however, apply broadly to object-oriented software architecture. Learning ObjectivesAfter studying this chapter, a learner should be able to: 1. Explain the five SOLID principles and relate them to cohesion, coupling, extensibility, and substitutability. 2. Apply dependency inversion and dependency injection in C++ designs. 3. Select and implement Factory, Strategy, Observer, Adapter, Decorator, Command, and State patterns. 4. Compare alternative designs using maintainability, testability, performance, and complexity criteria. 5. Recognize pattern misuse, accidental complexity, and inappropriate abstraction. 6. Evaluate design decisions in a realistic software-development case. From Classes to Design ResponsibilitiesA class should be understood not merely as a container of data and functions, but as a unit of responsibility. Responsibility concerns the reason a class exists and the kinds of changes that should affect it. If one class changes because of database schema modifications, user-interface redesign, tax rules, and logging requirements, it has multiple independent reasons to change. Such a class usually has low cohesion and becomes a source of defects. Coupling describes how strongly one component depends on another. Some coupling is necessary: a payment service must depend on an abstraction representing payment processing. The design problem is excessive or unstable coupling, especially when high-level policy depends directly on technical details. Good object-oriented design generally seeks high cohesion within components and controlled coupling between them. Abstraction is also a design decision rather than an automatic virtue. An abstraction is useful when it protects clients from variation, supports substitution, or clarifies a domain concept. An abstraction introduced only because two classes look superficially similar may be misleading. The SOLID PrinciplesSingle Responsibility PrincipleThe Single Responsibility Principle (SRP) states that a class should have one reason to change. The phrase does not mean that a class may contain only one method or perform only one tiny operation. It means that its responsibilities should belong to one coherent area of change. Consider a `Report` class that formats data, saves it to a file, sends it by email, and records audit entries. Each function may change for a different reason. A better design can separate `ReportFormatter`, `ReportStorage`, `EmailSender`, and `AuditLogger`. A coordinating service may use these components without owning all their implementation details. SRP improves cohesion and testing, but excessive decomposition is possible. Splitting every few lines into a class can obscure the domain and increase navigation overhead. The practical question is whether the responsibilities evolve together and whether separating them reduces meaningful change impact. Open/Closed PrincipleThe Open/Closed Principle (OCP) proposes that software entities should be open for extension but closed for modification. Existing, stable code should not require repeated changes whenever a new variation is introduced. Suppose an invoice calculator contains a growing chain of conditionals for standard, premium, student, and international customers. Introducing a `PricingPolicy` interface allows each policy to be added as a new class. The calculator is extended through new implementations rather than modified repeatedly. OCP is achieved through stable abstractions, polymorphism, composition, registration mechanisms, or data-driven rules. It does not mean that code must never be modified. If the abstraction is poorly understood, forcing extension around it can be worse than making a direct, clear modification. Liskov Substitution PrincipleThe Liskov Substitution Principle (LSP) requires objects of a subtype to be usable wherever objects of the supertype are expected without breaking correctness. Subtyping is therefore a behavioral contract, not merely a syntactic relationship. If `Rectangle` exposes independent setters for width and height, replacing it with a mathematically constrained `Square` may violate client expectations: setting the width might unexpectedly change the height. Although a square is a rectangle in mathematics, the particular software contract makes the inheritance relationship unsafe. LSP concerns preconditions, postconditions, invariants, and observable behavior. A subtype should not demand stronger preconditions, should honor promised postconditions, and should preserve important invariants. When inheritance creates exceptions, disabled methods, or surprising behavior, composition or a narrower interface is usually preferable. Interface Segregation PrincipleThe Interface Segregation Principle (ISP) states that clients should not be forced to depend on methods they do not use. A large `Machine` interface containing `print`, `scan`, `fax`, and `staple` operations is unsuitable for a simple printer. A client depending on the full interface may be forced to implement meaningless methods. Smaller role-oriented interfaces such as `Printer` and `Scanner` allow clients to depend only on required capabilities. ISP reduces accidental coupling and makes mocks and test doubles more focused. However, fragmentation also has a cost. Interfaces should represent meaningful client needs, not be divided into arbitrary single-method abstractions without evidence of independent use. Dependency Inversion PrincipleThe Dependency Inversion Principle (DIP) contains two related ideas: high-level policy should not depend directly on low-level implementation details, and both should depend on abstractions; abstractions should not depend on details, while details should depend on abstractions. For example, an `OrderService` should not construct a concrete `MySqlOrderRepository` internally. It should depend on an `OrderRepository` abstraction. A concrete repository is then supplied through a constructor: ```cpp class OrderRepository { public: virtual ~OrderRepository() = default; virtual void save(const Order&) = 0; }; class OrderService { OrderRepository& repository; public: explicit OrderService(OrderRepository& r) : repository(r) {} void place(const Order& order) { repository.save(order); } }; ``` This is constructor dependency injection. The service can be tested with an in-memory or mock repository, while the application composition root selects the production implementation. Dependency inversion is not the same as adding an interface to every class. The abstraction should be owned by the high-level client and should express the client’s required operations. Patterns as Reusable Design VocabularyA design pattern describes a recurring problem, the forces involved, and a general solution structure. It is not a code template that must be copied literally. Patterns are useful because they communicate design intent: a reviewer who sees a strategy object understands that an algorithm is intended to vary independently from its context. Patterns can be grouped broadly as creational, structural, and behavioral. The following patterns are particularly useful in C++ systems, although each must be selected according to a concrete design pressure. Creational and Structural ChoicesFactory PatternA Factory centralizes or encapsulates object creation. A client requests an abstraction without depending on the concrete class selected. A simple factory may use a function or a `switch`; a factory method may delegate creation to subclasses; an abstract factory creates related families of objects. ```cpp std::unique_ptr<Logger> makeLogger(LogType type) { if (type == LogType::File) return std::make_unique<FileLogger>(); return std::make_unique<ConsoleLogger>(); } ``` The factory is valuable when construction is complex, when the concrete type depends on configuration, or when clients should remain independent of implementations. It is less valuable when construction is trivial and visible creation improves readability. A factory that merely forwards `new` without hiding variation adds little value. Adapter PatternAn Adapter converts one interface into another expected by a client. It is especially useful when integrating legacy libraries, third-party APIs, or components whose interfaces cannot be changed. ```cpp class PaymentAdapter : public PaymentGateway { LegacyProcessor processor; public: bool charge(Money amount) override { return processor.process(amount.value()); } }; ``` The adapter isolates external assumptions at a boundary. It may also translate data formats, errors, units, or ownership conventions. A good adapter prevents vendor-specific types from spreading through the application. It should not silently change important semantics; such transformations belong in clearly named translation or anti-corruption layers. Decorator PatternA Decorator attaches additional behavior to an object while preserving the same interface. Logging, caching, authorization, compression, and retry behavior can be composed around a core service. ```cpp class ServiceDecorator : public Service { protected: std::unique_ptr<Service> wrapped; public: explicit ServiceDecorator(std::unique_ptr<Service> s) : wrapped(std::move(s)) {} }; ``` Decorators support flexible combinations without creating a subclass for every combination. Their risks include confusing ordering, excessive nesting, and duplicated state. A decorator should preserve the contract of the wrapped abstraction and make its added behavior explicit. Behavioral Patterns for Varying CollaborationStrategy PatternStrategy encapsulates interchangeable algorithms behind a common interface. A navigation application, for instance, can use separate strategies for fastest route, shortest route, or low-emission route. ```cpp class RouteStrategy { public: virtual ~RouteStrategy() = default; virtual Route calculate(const Map& map, Point from, Point to) = 0; }; class Navigator { std::unique_ptr<RouteStrategy> strategy; public: explicit Navigator(std::unique_ptr<RouteStrategy> s) : strategy(std::move(s)) {} Route route(const Map& m, Point a, Point b) { return strategy->calculate(m, a, b); } }; ``` Strategy is appropriate when an algorithm varies independently of the context or when algorithms must be selected at runtime. If there are only two stable alternatives and no expected variation, a simple function or conditional may be clearer. Observer PatternObserver establishes a one-to-many relationship in which observers are notified when a subject changes. User-interface events, domain notifications, and monitoring systems often use this idea. The subject maintains subscribers and publishes an event. In modern C++, callbacks can implement lightweight observation, while stronger domain events may use explicit event types. Important design concerns include subscription lifetime, notification order, reentrancy, thread safety, and exception handling. RAII-based subscription tokens can automatically unregister observers and reduce dangling-callback errors. Observer reduces direct coupling but may make control flow implicit. A developer reading the subject may not know which observers run or whether an observer triggers another event. For critical workflows, explicit orchestration or a message broker may be more suitable. Command PatternCommand encapsulates a request as an object. A command can contain the operation, its parameters, and sometimes undo information. This supports queues, logging, transactions, retries, scheduling, and undo/redo. For example, a text editor may represent insertion as `InsertTextCommand`, with `execute()` and `undo()` operations. A remote-control system may store commands independently of the devices that execute them. Command is especially useful when the sender should not know the receiver’s implementation. Command introduces object and lifecycle management. It should not be used merely to wrap every method call unless requests genuinely need to be delayed, recorded, retried, or reversed. State PatternState allows an object to alter its behavior when its internal state changes, while appearing to change its class. A network connection may be `Disconnected`, `Connecting`, or `Connected`, with different responses to `send()` or `close()` in each state. State replaces large conditional structures with state objects or a well-defined transition model. It is useful when behavior depends on state in many operations and transitions are becoming difficult to maintain. For a small finite state machine, an enum and a clear transition table may be superior. State objects can also introduce circular references and ownership complexity, so transitions must be designed carefully. A Method for Selecting a PatternPattern selection should begin with the design problem, not with the desire to use a named pattern. A useful sequence is: 1. Identify what varies: creation, algorithm, external interface, behavior, lifecycle, or state. 2. Identify who should know about that variation. 3. Define the smallest stable abstraction required by clients. 4. Compare a direct solution with one or more patterns. 5. Evaluate testability, runtime cost, ownership, error behavior, and cognitive load. 6. Introduce the pattern only if it reduces a demonstrated source of change or coupling. This process also reveals relationships among patterns. A Factory may create Strategy objects. Decorators may wrap a Strategy. An Adapter may be decorated with logging. Commands may trigger State transitions and publish Observer events. Combining patterns is legitimate when each solves a distinct problem; combining them merely because they are available produces architectural noise. Illustrative Case Study: Modernizing a Payment Processing ModuleBackgroundThe following fictional case describes a mid-sized online retailer. Its original C++ payment module contained a `PaymentManager` class that selected a gateway, formatted requests, logged transactions, sent notifications, and handled retries. The class had grown through successive business changes. Situation and ProblemAdding a new gateway required editing a large conditional statement. Unit tests invoked real gateway code because construction occurred inside `PaymentManager`. A gateway outage caused retry logic to duplicate notifications, and a legacy provider used a different amount format. The module was operational but expensive to change and difficult to test. AnalysisThe class violated SRP because gateway selection, payment policy, formatting, notification, and logging changed independently. It violated DIP because the high-level payment workflow constructed concrete gateways. The legacy provider’s interface did not match the application contract, creating an integration problem suited to Adapter. Retry behavior and fraud checks were algorithmic variations suited to Strategy or decorators, depending on whether they represented the main payment policy or cross-cutting behavior. Approach and SolutionThe team first defined a narrow `PaymentGateway` abstraction owned by the payment application. Concrete gateways implemented it directly or through an Adapter. A Factory selected a gateway based on configuration. A `PaymentService` received the gateway, transaction repository, and notifier through constructor injection. Fraud evaluation was represented as a Strategy because different merchants selected different evaluation algorithms. Logging and metrics were implemented as decorators around the gateway, ensuring that the core gateway remained focused. A Command represented a payment request placed on a durable queue; the command handler performed the operation and recorded an idempotency key. Observer-style domain events notified reporting and customer-notification components after a successful transaction, rather than mixing notification into the gateway. The design also specified failure semantics. A gateway timeout could produce an unknown payment status, so retrying was permitted only when the idempotency key was preserved. Observers were not allowed to alter the payment result. Ownership was expressed using `std::unique_ptr` where components had exclusive lifetime and references where the composition root retained ownership. OutcomeThe new gateway could be added without modifying the payment workflow. Tests used fakes and deterministic strategies. Logging and metrics were enabled through composition rather than copied into each gateway. The system contained more types than before, but each type had a clearer reason to change. The team deliberately did not introduce a general-purpose event bus or abstract factory because the current gateway family did not require those mechanisms. Learning PointsThe case demonstrates that SOLID principles identify pressure points, while patterns provide possible responses. No single pattern solved the entire problem. Dependency inversion improved testability; Adapter isolated a legacy contract; Factory isolated construction; Strategy represented policy variation; decorators handled cross-cutting behavior; Command supported reliable processing; and events decoupled secondary reactions. The solution remained maintainable because each abstraction had a concrete purpose. Risks, Misuse, and Critical EvaluationA common mistake is treating SOLID as a rule that every class must have one method, every dependency must be an interface, and inheritance is always undesirable. These interpretations replace design judgment with ceremony. Another mistake is creating abstractions before identifying variation. Premature abstractions often freeze an incorrect model and make simple changes difficult. Pattern misuse appears in several forms. A Singleton can conceal global mutable state and complicate tests. An Observer network can create invisible side effects and memory-lifetime bugs. A Factory may become a large conditional bottleneck. Deep Decorator chains can make debugging difficult. State classes can be excessive for a simple enumeration. Command objects can add serialization and lifecycle overhead when no queuing or undo is required. Performance must also be considered. Virtual dispatch, heap allocation, indirection, and synchronization may matter in high-frequency or resource-constrained systems. They are not automatically unacceptable, but they should be measured. Value semantics, templates, `std::variant`, function objects, or compile-time strategies may be appropriate alternatives in performance-sensitive C++ code. Governance and professional responsibility are relevant when patterns are used in systems handling payments, health information, identity, or safety-related operations. Clear ownership, auditability, deterministic error handling, secure logging, least-privilege interfaces, and protection of sensitive data are more important than pattern conformity. Design documentation should explain why an abstraction exists, what contract it guarantees, and which risks remain. Contemporary C++ and Emerging PracticeModern C++ supports many pattern implementations through the standard library. Lambdas and `std::function` can represent lightweight strategies or observers. `std::variant` with `std::visit` can model closed state sets without inheritance. Concepts can express compile-time requirements, while smart pointers make ownership explicit. Modules and improved tooling can reduce dependency-management problems. Large systems increasingly combine object-oriented components with functional techniques, message-driven architecture, dependency-injection frameworks, and automated testing. Static analysis, sanitizers, code review tools, and architecture tests can detect ownership errors, interface violations, and unwanted dependencies. AI-assisted coding may generate pattern-shaped code quickly, but generated abstractions still require human evaluation of contracts, security, performance, and actual business variation. Best Practices for Professional Use- Start from a change scenario, defect pattern, or integration constraint.
- Keep abstractions small, behaviorally precise, and owned by the client that needs them.
- Prefer composition when behavior varies or when inheritance would weaken substitutability.
- Use constructor injection for required dependencies and make ownership explicit.
- Isolate third-party and legacy interfaces at system boundaries.
- Test contracts and interactions, not merely class existence.
- Document event ordering, failure semantics, thread assumptions, and lifetime rules.
- Measure performance before replacing a clear design with a more complex optimization.
- Remove an abstraction when its variation disappears or its cost exceeds its benefit.
Chapter SummarySOLID principles guide the distribution of responsibilities and dependencies in object-oriented systems. SRP promotes coherent responsibilities; OCP protects stable code from repeated modification; LSP defines behavioral substitutability; ISP limits client dependence on unused operations; and DIP separates high-level policy from technical detail through stable abstractions. Dependency injection is a practical technique for applying DIP and improving testability. Design patterns capture recurring solutions but should be selected according to actual design forces. Factory addresses variable construction, Strategy variable algorithms, Observer notification, Adapter incompatible interfaces, Decorator composable behavior, Command reified requests, and State behavior dependent on internal state. Patterns must be balanced against complexity, performance, ownership, transparency, and maintainability. The central professional skill is not recognizing patterns but deciding when not to use them. Key TermsSOLID: Five object-oriented design principles concerning responsibility, extension, substitution, interfaces, and dependencies. Single Responsibility Principle: The principle that a class should have one coherent reason to change. Open/Closed Principle: The principle that stable code should permit extension without repeated modification. Liskov Substitution Principle: The requirement that subtypes preserve the behavioral contract of their supertypes. Interface Segregation Principle: The principle that clients should not depend on unused interface operations. Dependency Inversion Principle: The principle that high-level policy and low-level details should depend on suitable abstractions. Dependency Injection: Supplying an object’s dependencies externally rather than constructing them internally. Cohesion: The degree to which the responsibilities of a component belong together. Coupling: The degree of dependency between components. Factory: An object or function that encapsulates creation and selection of concrete objects. Strategy: An encapsulated, interchangeable algorithm. Observer: A one-to-many notification relationship between a subject and subscribers. Adapter: A component that converts one interface into another expected interface. Decorator: A wrapper that adds behavior while preserving an object’s interface. Command: An object representing a request, often supporting queuing, logging, or undo. State: A design in which an object’s behavior varies according to its current state. Composition: Building behavior by combining objects rather than relying primarily on inheritance. Overengineering: Adding unnecessary abstraction or complexity beyond the problem’s requirements. Idempotency: A property allowing repeated processing of an operation without unintended repeated effect. Review QuestionsShort Answer Questions1. What does “one reason to change” mean in the Single Responsibility Principle? 2. How does constructor dependency injection support the Dependency Inversion Principle? 3. Distinguish an Adapter from a Decorator. 4. Why can inheritance violate the Liskov Substitution Principle? 5. Identify two risks associated with the Observer pattern. Long Answer Questions1. Explain all five SOLID principles using a cohesive C++ service example. 2. Compare Factory, Strategy, and State patterns in terms of the variation each encapsulates. 3. Discuss how dependency inversion improves testability, ownership clarity, and architectural flexibility. 4. Explain the benefits and risks of combining Adapter, Decorator, and Observer in an integration-heavy system. 5. Critically evaluate why design patterns should not be applied mechanically. Analytical / Application Questions1. A `DocumentManager` loads files, validates content, converts formats, stores documents, and emails users. Analyze its design using SOLID and propose a restructuring. 2. Design a pattern-based solution for a transport application supporting car, bicycle, and public-transport route algorithms, including runtime selection and testing. 3. A notification system uses observers, but subscribers are frequently destroyed and callbacks sometimes trigger recursive notifications. Diagnose the risks and propose a safer design. 22. Multiple Choice Questions with AnswersQuestion 1A class constructs a concrete database repository internally, making unit testing difficult. Which change most directly applies Dependency Inversion? A. Add more methods to the repository class B. Pass a repository abstraction into the class constructor C. Make the repository a Singleton D. Move the database code into a larger class Correct Answer: B. Pass a repository abstraction into the class constructor Question 2Which situation best justifies the Strategy pattern? A. A legacy API has incompatible method names B. Several algorithms must be selected or exchanged at runtime C. An object must notify many subscribers D. An object needs additional logging behavior Correct Answer: B. Several algorithms must be selected or exchanged at runtime Question 3A wrapper adds authorization and caching while preserving the same service interface. Which pattern is being used? A. Adapter B. Factory C. Decorator D. Command Correct Answer: C. Decorator Question 4Which statement best expresses the Liskov Substitution Principle? A. Every class must have exactly one method B. Subtypes must preserve the behavioral expectations of their supertypes C. All dependencies must be concrete classes D. Objects should never use inheritance Correct Answer: B. Subtypes must preserve the behavioral expectations of their supertypes Question 5What is the principal purpose of an Adapter? A. To create families of related objects B. To convert one interface into another expected by a client C. To store a request for later execution D. To represent internal state transitions Correct Answer: B. To convert one interface into another expected by a client Question 6Which design most clearly violates the Single Responsibility Principle? A. A route strategy calculates one type of route B. A repository persists orders C. A payment class selects gateways, formats requests, logs, retries, and sends emails D. A factory creates configured gateway objects Correct Answer: C. A payment class selects gateways, formats requests, logs, retries, and sends emails Question 7A text editor needs undo and redo operations. Which pattern most directly represents each user action as a reusable object? A. Command B. Observer C. Adapter D. Factory Correct Answer: A. Command Question 8When is a simple enum and transition table often preferable to the State pattern? A. When behavior varies across many operations and states B. When the state model is small and easily expressed directly C. When an external interface must be translated D. When objects require runtime algorithm substitution Correct Answer: B. When the state model is small and easily expressed directly Question 9Which is a significant risk of an uncontrolled Observer network? A. It eliminates all coupling B. It guarantees deterministic notification order C. It can create hidden side effects and lifetime problems D. It prevents event-driven behavior Correct Answer: C. It can create hidden side effects and lifetime problems Question 10Why can introducing an interface for every class be poor design? A. Interfaces cannot be used in C++ B. It may add abstraction and indirection without protecting meaningful variation C. Interfaces always violate OCP D. Interfaces prevent dependency injection Correct Answer: B. It may add abstraction and indirection without protecting meaningful variation
|