5. Software Design Principles and PatternsIntroduction and ContextSoftware architecture provides the large-scale structure of an application, while software design determines how that structure is expressed in modules, classes, interfaces, functions, and collaborations. In full stack development, design decisions influence far more than code organization. They affect testability, security, performance, maintainability, deployment, team coordination, and the ability to adapt when business requirements change. A system may function correctly and still be poorly designed. A class that performs database access, validates input, applies business rules, formats responses, and sends notifications may work initially, but it creates strong coupling and becomes difficult to change. Design principles provide general guidance for avoiding such problems. Design patterns provide reusable solutions to recurring design situations. Neither principles nor patterns are recipes to be applied mechanically. They are tools for reasoning about responsibilities, dependencies, variation, and collaboration. This chapter develops a practical and analytical understanding of abstraction, encapsulation, composition, SOLID, GRASP, dependency inversion, and separation of concerns. It then examines selected creational, structural, and behavioral patterns: Factory, Builder, Adapter, Facade, Decorator, Strategy, Observer, Command, and State. Particular attention is given to trade-offs, pattern misuse, and the relationship between design quality and professional software development. Learning ObjectivesAfter studying this chapter, the learner should be able to: - explain abstraction, encapsulation, composition, cohesion, coupling, and separation of concerns;
- apply the SOLID and GRASP principles to full stack application design;
- distinguish dependency inversion from dependency injection;
- evaluate creational, structural, and behavioral design patterns;
- select and implement suitable patterns in realistic application scenarios;
- identify overengineering, inappropriate abstraction, and other design anti-patterns;
- assess design choices in relation to maintainability, testing, security, performance, and organizational context.
Designing for ChangeA useful design question is not merely “Does this code work?” but “What kinds of change is this design prepared to absorb?” Changes may involve a new payment provider, a revised tax rule, a different user-interface channel, a new persistence technology, or a requirement to test a component in isolation. Good design localizes likely changes and prevents unrelated components from being affected. Abstraction, Encapsulation, and Information HidingAbstraction represents essential behavior while omitting irrelevant implementation detail. A `PaymentGateway` interface may expose `authorize(amount)` without revealing whether the implementation uses a bank API, a wallet provider, or a sandbox. Abstraction is therefore a modeling decision: it defines what a client needs to know. Encapsulation places data and the operations that protect its validity within a boundary. A `BankAccount` should not expose its balance for arbitrary modification; instead, it may provide `deposit` and `withdraw` operations that enforce rules. Information hiding is closely related but emphasizes concealing design decisions likely to change. A module may hide its database schema, caching mechanism, or third-party API details. Poor abstraction is often more harmful than no abstraction. An interface with dozens of methods may expose implementation details and force clients to depend on capabilities they do not use. Effective abstraction is small, meaningful, and aligned with a stable responsibility. Composition over InheritanceInheritance expresses an “is-a” relationship and can support polymorphism, but it also creates strong coupling between superclass and subclass. Composition constructs behavior by combining objects. A notification service, for example, can receive a formatter, a message sender, and a retry policy rather than inheriting from a large framework class. Composition makes behavior replaceable at runtime and generally supports more focused tests. Inheritance remains appropriate when the subtype genuinely satisfies the behavioral contract of the supertype and the relationship is stable. The danger is using inheritance merely to reuse code. Reuse alone does not establish a sound substitutability relationship. Cohesion, Coupling, and Separation of ConcernsCohesion measures how closely related the responsibilities within a module are. A class dedicated to calculating shipping charges has higher cohesion than a “utility” class containing unrelated validation, logging, formatting, and database functions. Coupling describes the degree of dependency between modules. High coupling makes change propagation more likely. Separation of concerns assigns distinct aspects of a system to distinct components or layers. In a web application, routing, authentication, domain rules, persistence, presentation, and infrastructure integration may be separated. Separation does not necessarily mean one class per method or one service per feature. Excessive fragmentation can make a system harder to understand. The objective is meaningful boundaries, not maximum division. SOLID and GRASP as Design Reasoning ToolsSOLID is a group of object-oriented design principles. GRASP, or General Responsibility Assignment Software Patterns, offers guidance for assigning responsibilities to objects. Both are heuristics rather than laws. Their value lies in helping designers explain and evaluate decisions. The Single Responsibility PrincipleThe Single Responsibility Principle states that a class or module should have one reason to change. The phrase “reason to change” refers to a coherent responsibility, not necessarily a single method. An invoice service that calculates totals, persists invoices, and sends email has several independent reasons to change. Separating these concerns reduces ripple effects. A practical approach is to identify distinct stakeholders or policies. Tax calculation may change because of regulatory policy; persistence may change because of infrastructure; email delivery may change because of communication requirements. These responsibilities can be coordinated by an application service without being implemented by one large class. The Open–Closed PrincipleThe Open–Closed Principle recommends that software entities be open for extension but closed for modification. A pricing engine can support new discount policies by accepting implementations of a `DiscountPolicy` interface rather than repeatedly editing a conditional statement. The principle does not require predicting every future variation. Prematurely creating extension points for imaginary requirements can produce needless complexity. Extension should be introduced where variation is real, probable, and sufficiently costly to implement through modification. The Liskov Substitution PrincipleThe Liskov Substitution Principle requires that objects of a subtype be usable wherever objects of the declared supertype are expected without violating correctness. A read-only collection should not be represented as a mutable collection whose `add` operation unexpectedly fails. A subclass that strengthens preconditions, weakens guarantees, or changes essential semantics may violate substitutability. This principle concerns behavior, not merely matching method signatures. Interfaces should express contracts that implementations can honor consistently. The Interface Segregation PrincipleThe Interface Segregation Principle advises clients not to depend on methods they do not use. Instead of a large `UserOperations` interface containing account creation, reporting, password reset, and administrative functions, smaller role-oriented interfaces can be defined. This reduces accidental coupling and makes implementations easier to test. The Dependency Inversion PrincipleThe Dependency Inversion Principle has two related claims: high-level policy should not depend directly on low-level detail, and both should depend on abstractions; abstractions should not depend on details, while details depend on abstractions. A checkout use case should depend on a payment abstraction rather than a specific payment SDK. Dependency inversion is a design relationship. Dependency injection is a technique for supplying the required dependency, commonly through a constructor, factory, or framework container. Injection can implement dependency inversion, but using a container does not automatically produce good design. If a high-level class still depends on a concrete infrastructure concept, the underlying inversion has not been achieved. GRASP Responsibility AssignmentGRASP complements SOLID by asking who should know or do something. Information Expert assigns responsibility to the object possessing the necessary information. A `Cart` is a natural expert for calculating its subtotal because it owns the line items. Creator suggests assigning object creation to a class that aggregates, contains, records, or closely uses the created object. Controller assigns system-operation responsibility to an application-facing object, such as an order controller or use-case handler, rather than placing business coordination in a user-interface component. Low Coupling and High Cohesion encourage focused responsibilities and limited dependency. Polymorphism assigns behavior that varies by type to polymorphic implementations. Pure Fabrication introduces a service object when placing a responsibility in a domain object would reduce cohesion. Indirection and Protected Variations help isolate unstable elements behind stable interfaces. Families of Design PatternsPatterns describe a recurring problem, a context, a proposed arrangement of responsibilities, and consequences. They are not libraries and do not prescribe identical code in every language. A pattern should be selected because it clarifies a design pressure, not because its name appears in a catalogue. Creational Patterns: Controlling Object Construction#### Factory A Factory centralizes or abstracts object creation. A notification factory may return an email, SMS, or push-notification sender based on configuration or request data. The client depends on a common abstraction and does not contain construction logic for every concrete product. Factories are useful when construction is conditional, complex, or likely to vary. They can also become a hidden service locator if they return unrelated objects or conceal too many dependencies. A simple constructor is preferable when no meaningful creation decision exists. #### Builder Builder separates the construction of a complex object from its representation. It is useful when an object has many optional parameters, validation rules, or construction stages. For example, a report request builder may configure date range, filters, sorting, pagination, and output format before producing an immutable request. Builders improve readability and can enforce valid construction sequences. They add ceremony, however, and are unnecessary for small objects with a few stable parameters. In languages supporting named or default arguments, those features may provide a simpler alternative. Structural Patterns: Organizing Collaboration#### Adapter An Adapter converts one interface into another expected by a client. Suppose an application expects `CurrencyConverter.convert`, while a legacy provider exposes `exchange`; an adapter translates the call and maps provider-specific errors into application-level errors. Adapters protect the domain from external interface changes. #### Facade A Facade offers a simplified interface to a subsystem. An order-placement facade may coordinate inventory reservation, payment authorization, shipment creation, and notification. The facade reduces the client’s knowledge of subsystem classes, but it should not become a dumping ground for all business logic. It is most effective when it provides a coherent use-case boundary. #### Decorator A Decorator adds behavior to an object by wrapping it while preserving the same interface. A request handler might be decorated with authentication, logging, caching, or metrics. Decorators support flexible combinations without creating a subclass for every combination. Their order can matter: caching before authorization is not equivalent to authorization before caching. Decorator chains must therefore be documented and tested, particularly when behavior involves security, transactions, or error handling. Behavioral Patterns: Managing Variation and Interaction#### Strategy Strategy encapsulates an interchangeable algorithm behind a common interface. Shipping calculation, password hashing, recommendation, and tax computation are common examples. A checkout service can receive a `ShippingStrategy` and select a ground, express, or international implementation. Strategy is valuable when algorithms vary independently of the client. It is less useful when there is only one stable algorithm or when the abstraction merely renames a single function. #### Observer Observer establishes a one-to-many relationship in which observers are notified when a subject changes. In an application, an order event may be observed by inventory, analytics, and notification components. Observer reduces direct coupling but introduces lifecycle and delivery concerns. Synchronous observers can slow the initiating operation; asynchronous observers may deliver late, repeatedly, or out of order. In distributed systems, event publication should include clear event contracts, idempotent consumers, failure handling, and appropriate privacy controls. An in-process observer is not automatically equivalent to a reliable message broker. #### Command Command represents an operation as an object. A command may contain the request to approve a refund, execute it through a handler, and record its status. This supports queuing, logging, retries, authorization checks, scheduling, and sometimes undo operations. Command objects should not be used simply to wrap every method. Their value arises when an action must be treated as data or managed independently from the caller. #### State State allows an object to alter its behavior when its internal state changes. An order may move among Draft, Paid, Shipped, and Cancelled states, with different permitted operations in each state. State objects can replace large conditional blocks and make transition rules explicit. State modeling must define valid transitions, persistence, concurrency behavior, and recovery after failure. A state pattern may be unnecessary for a small, stable enumeration whose rules are simple and localized. An Applied Case: Refactoring an Online Marketplace CheckoutBackgroundThis illustrative case concerns “MarketHub,” a fictional online marketplace with a web application and mobile client. Its initial checkout module was developed quickly and contained payment calls, discount rules, inventory updates, order persistence, email notifications, and HTTP response formatting in one controller. Situation and ProblemThe team needed to add a second payment provider, support promotional strategies, expose checkout through an API and a scheduled order-import process, and introduce reliable notifications. Changes to payment code repeatedly broke tests for discounts. The controller was difficult to test because it required a database, external payment credentials, and an email server. AnalysisThe design exhibited low cohesion and high coupling. The controller had several reasons to change, violating the Single Responsibility Principle. It depended directly on low-level SDKs, conflicting with dependency inversion. Conditional statements selected payment and discount behavior, making extension increasingly risky. Notification logic was also embedded in the transaction flow. ApproachThe team first defined a `CheckoutUseCase` as an application-level controller. It accepted abstractions for order storage, payment authorization, inventory reservation, discount calculation, and event publication. Concrete SDKs were placed behind adapters. A Factory selected the appropriate payment gateway from configuration. Strategy implementations represented discount policies. A Facade coordinated the checkout steps while keeping provider-specific details outside the domain. Completion published an `OrderPlaced` event, allowing notification and analytics consumers to operate independently. A State model represented order status and prevented invalid actions, such as shipping an unpaid order. The team deliberately avoided creating a separate microservice for each pattern. The refactoring remained within a modular application because the operational cost of distributed deployment was not justified. Automated tests used fake gateways and in-memory repositories, while contract tests checked the adapters against provider expectations. OutcomeThe checkout use case became testable without external services. Adding a new payment provider required a new adapter and configuration rather than changes to pricing rules. Notification failures could be retried independently, and order transitions were explicit. The design did introduce more interfaces and configuration, so the team documented the boundaries and removed abstractions that had no actual variation. Learning PointsThe case demonstrates that patterns are most effective when derived from concrete design pressures. Factory and Strategy localized variation; Adapter protected the application from external interfaces; Facade simplified coordination; Observer-like events reduced direct coupling; and State made lifecycle rules visible. Equally important, the team retained a modular monolith because architecture should reflect operational needs rather than fashion. Common Misuse and Anti-PatternsPattern enthusiasm can produce “patternitis”: applying named structures where a direct solution is clearer. Excessive abstraction creates indirection, cognitive load, and more code to maintain. A factory for a single uncomplicated class, a facade that merely forwards every method, or a strategy interface with one permanent implementation may add little value. The God Object accumulates unrelated responsibilities and becomes a bottleneck for change. The Anemic Domain Model places all meaningful rules in procedural services while domain objects merely hold data; this may be suitable for some transaction-oriented systems but is problematic when rich invariants belong to domain concepts. The Service Locator hides dependencies and makes tests difficult because required collaborators cannot be seen in a constructor. Circular dependencies indicate unclear ownership or misplaced responsibilities. Copy-and-paste reuse causes inconsistent fixes. Deep inheritance hierarchies make behavior difficult to predict. A distributed monolith divides a system into networked services without achieving meaningful autonomy, thereby adding latency and operational complexity. Developers should also avoid confusing dependency injection with dependency inversion, or asynchronous events with guaranteed delivery. Quality, Security, and Governance ConsiderationsDesign principles have professional and governance implications. Encapsulation can protect sensitive data by limiting exposure, but access control must still be enforced at the appropriate boundary. Logging decorators and observers must avoid recording passwords, payment credentials, tokens, or unnecessary personal data. Event schemas should have ownership, versioning, retention rules, and privacy review. Dependency boundaries should be examined for supply-chain risk. Adapters isolate external libraries, but they do not eliminate the need to assess licenses, vulnerabilities, authentication behavior, and provider reliability. Command handlers must authorize the requested action, not merely validate its shape. State transitions should be auditable where financial, medical, or regulatory consequences exist. Patterns also affect operational governance. A retrying decorator can duplicate a payment unless the command is idempotent. An observer can create hidden side effects that complicate incident analysis. Architectural decisions should therefore record assumptions, failure modes, ownership, and measurable reasons for introducing complexity. Contemporary Practice and Best PracticesModern full stack systems increasingly combine object-oriented patterns with functional techniques, dependency injection frameworks, event-driven architecture, typed interfaces, and automated observability. The underlying principles remain stable even when implementation styles change. A TypeScript service, a Java backend, and a Python application can all apply dependency inversion and Strategy, although their syntax and idioms differ. Useful practices include: - begin with responsibilities and variation points rather than pattern names;
- keep abstractions narrow and express domain language clearly;
- prefer composition when behavior must vary independently;
- make dependencies visible and inject them at composition boundaries;
- test contracts and interactions, not only internal implementation details;
- model state transitions explicitly when lifecycle rules are significant;
- treat retries, events, caching, and decorators as sources of side effects;
- measure complexity and operational cost before splitting modules or services;
- remove abstractions that have no demonstrated purpose.
AI-assisted coding can generate implementations of familiar patterns quickly, but it may also introduce unnecessary factories, speculative interfaces, insecure logging, or hidden dependencies. Human review remains necessary to assess responsibility, contracts, privacy, failure behavior, and fit with the existing architecture. The quality of a design is determined by the problems it resolves, not by the number of patterns it contains. Chapter SummarySoftware design principles guide the assignment of responsibilities and the management of change. Abstraction exposes essential behavior, encapsulation protects invariants, composition combines replaceable behavior, and separation of concerns establishes meaningful boundaries. Cohesion should be increased and unnecessary coupling reduced. SOLID and GRASP provide complementary reasoning tools. SRP limits unrelated reasons to change; OCP supports controlled extension; LSP protects behavioral substitutability; ISP keeps interfaces focused; and DIP separates high-level policy from low-level detail. GRASP assists with decisions about information ownership, creation, coordination, polymorphism, and protected variation. Factory and Builder address object creation; Adapter, Facade, and Decorator organize structural collaboration; Strategy, Observer, Command, and State manage variation and interaction. Each pattern has benefits, costs, and conditions of suitability. Effective professionals avoid pattern misuse, hidden dependencies, God Objects, inappropriate inheritance, and distributed complexity. Patterns should be selected deliberately, tested in context, and governed with attention to security, privacy, reliability, and operational consequences. Key TermsAbstraction: Representation of essential behavior while omitting irrelevant implementation detail. Encapsulation: Bundling data and related operations behind a controlled boundary. Information hiding: Concealing design decisions likely to change from dependent clients. Composition: Building behavior by combining collaborating objects. Cohesion: Degree to which responsibilities within a module belong together. Coupling: Degree of dependency between modules or components. Separation of concerns: Organization of distinct system concerns into appropriate boundaries. SOLID: Five object-oriented principles concerning responsibility, extension, substitutability, interfaces, and dependencies. Dependency inversion: Design in which high-level policy and low-level detail depend on suitable abstractions. Dependency injection: Technique of supplying an object’s dependencies from outside the object. GRASP: Guidance for assigning responsibilities to objects and classes. Factory: Mechanism that abstracts or centralizes object creation. Builder: Pattern that separates complex object construction from its representation. Adapter: Object that converts one interface into another expected by a client. Facade: Simplified interface to a larger subsystem. Decorator: Wrapper that adds behavior while preserving an object’s interface. Strategy: Encapsulated, interchangeable algorithm. Observer: One-to-many notification relationship between a subject and interested observers. Command: Object representation of an operation or request. State: Pattern that changes object behavior according to its internal state. Review QuestionsShort Answer Questions1. Distinguish abstraction from encapsulation. 2. Why is composition often preferred to inheritance for behavior reuse? 3. What does the Dependency Inversion Principle require? 4. What problem does an Adapter solve? 5. State one risk associated with Observer implementations. Long Answer Questions1. Explain the five SOLID principles using full stack application examples. 2. Compare Factory and Builder, including the circumstances in which each is appropriate. 3. Discuss how GRASP supports responsibility assignment in an application architecture. 4. Explain Strategy, Command, and State, and compare their purposes. 5. Analyze the benefits and risks of using Facade, Decorator, and Adapter around external subsystems. Analytical / Application Questions1. A checkout controller directly selects payment providers, calculates discounts, writes orders, and sends email. Identify the design problems and propose a pattern-informed redesign. 2. A team wants to split every module into a separate microservice because it uses several design patterns. Evaluate this decision using coupling, cohesion, operational complexity, and business variation. 3. An asynchronous order event sometimes causes duplicate notifications and exposes customer data in logs. Analyze the likely design and governance issues and recommend corrective measures. 22. Multiple Choice Questions with AnswersQuestion 1A checkout service depends directly on a specific payment SDK. Which redesign most directly applies dependency inversion? A. Move all payment code into the checkout controller B. Define a payment abstraction and place the SDK behind an adapter C. Duplicate the checkout service for every payment provider D. Make the payment SDK a global singleton Correct Answer: B. Define a payment abstraction and place the SDK behind an adapter Question 2Which situation most strongly justifies using a Builder? A. Creating a small object with two mandatory values B. Selecting one of several notification implementations C. Constructing an object with many optional fields and validation rules D. Converting a legacy interface into a modern interface Correct Answer: C. Constructing an object with many optional fields and validation rules Question 3A module calculates tax, stores invoices, and sends email. Which principle is most clearly violated? A. Single Responsibility Principle B. Liskov Substitution Principle C. Interface Segregation Principle D. Open–Closed Principle Correct Answer: A. Single Responsibility Principle Question 4Which pattern converts an external provider’s interface into the interface required by an application? A. Facade B. Adapter C. Observer D. State Correct Answer: B. Adapter Question 5A shipping service receives interchangeable ground, express, and international algorithms. Which pattern is most appropriate? A. Strategy B. Builder C. Command D. Facade Correct Answer: A. Strategy Question 6What is a significant risk of an asynchronous Observer-style event arrangement? A. It always eliminates network failures B. It prevents all duplicate processing C. Delivery order, retries, and failure handling may become complex D. It requires every observer to inherit from the subject Correct Answer: C. Delivery order, retries, and failure handling may become complex Question 7Which example best represents a Command? A. An object that wraps a request to approve and execute a refund B. A class that exposes a simplified subsystem interface C. A class that converts one API into another D. An object that stores only the current order status Correct Answer: A. An object that wraps a request to approve and execute a refund Question 8Why might the State pattern be preferable to a large conditional block for order processing? A. It removes the need to define valid transitions B. It makes state-specific behavior and transitions explicit C. It guarantees persistence consistency automatically D. It prevents all concurrency problems Correct Answer: B. It makes state-specific behavior and transitions explicit Question 9Which design choice is most likely to indicate pattern misuse? A. Using an adapter for a third-party API B. Using a strategy where algorithms genuinely vary C. Creating a factory for one simple class with no variation D. Using a facade to coordinate a coherent use case Correct Answer: C. Creating a factory for one simple class with no variation Question 10In the MarketHub case, why were notification consumers separated from checkout completion? A. To increase direct coupling between all modules B. To allow notification work and failures to be handled independently C. To ensure notifications could never be duplicated D. To eliminate the need for event contracts Correct Answer: B. To allow notification work and failures to be handled independently
|