Software design, though often considered a separate phase, is a significant activity during construction, especially on small projects. For larger projects, even with a formal architecture, a programmer frequently handles detailed design. Recognizing design as an explicit activity, even when informal, maximizes its benefits. This chapter focuses on design challenges and key heuristics at the construction level.
Design Challenges
“Software design” is the process of creating a plan to turn a software specification into operational code, linking requirements to implementation. A good design provides a robust structure for lower-level designs. Design, however, is marked by several challenges:
- Design Is a Wicked Problem: A “wicked problem” is one that can only be fully understood by partially solving it. In software, this means you often must “solve” a problem once (e.g., build a prototype) to fully define it before building the final solution. The Tacoma Narrows bridge collapse is a physical example: engineers only learned the importance of aerodynamics by building a bridge that failed. Unlike school assignments, professional programming is full of such wicked problems.
- Design Is a Sloppy Process (Even If It Produces a Tidy Result): The process is messy because it involves false starts and mistakes. Making and correcting design mistakes is far cheaper than correcting them in code. Design is also open-ended, and it’s hard to know when a design is “good enough,” leading to the common conclusion that you’re done when you run out of time.
- Design Is About Tradeoffs and Priorities: Designers must weigh competing goals (e.g., speed vs. development cost, memory usage vs. correctness) and choose a design that balances these characteristics based on project priorities.
- Design Involves Restrictions: Design is not just about creating possibilities, but also about restricting them. Just as limited resources constrain physical construction, imposing constraints on software design forces simplifications that ultimately lead to a better solution.
- Design Is Nondeterministic: There is no single “right” design. Three different people can create three vastly different but equally acceptable designs for the same program.
- Design Is a Heuristic Process: Because it’s nondeterministic, design relies on heuristics—“rules of thumb” or “things to try that sometimes work”—rather than repeatable processes guaranteed to produce a predictable result.It involves trial and error.
- Design Is Emergent: Designs do not appear fully formed. They evolve and improve through reviews, discussions, and the iterative process of coding and revising the code itself.
Key Design Concepts
Good software design hinges on understanding key concepts, including complexity management, desirable design characteristics, and levels of design.
Software’s Primary Technical Imperative: Managing Complexity
Fred Brooks’s “No Silver Bullets” distinguishes between essential difficulties (inherent to the problem) and accidental difficulties (introduced by the tools/methods used).
- Accidental difficulties (e.g., clumsy syntax, batch processing, poor tool integration) have largely been addressed by language evolution and improved environments.
- Essential difficulties remain challenging because software fundamentally involves precisely detailing intricate, interlocking concepts of a complex, often disorderly, real world. This includes identifying dependencies, handling exceptions, and ensuring exact correctness. As software tackles larger real-world problems, essential complexity increases.
The root of all these difficulties is complexity. While project failures are often non-technical (requirements, planning, management), when technical reasons are cited, uncontrolled complexity is usually the culprit. When no one understands code’s impact, progress halts. Therefore, managing complexity is the most important technical imperative in software development.
Pioneers like Edsger Dijkstra noted the staggering semantic levels in computing (e.g., bit to gigabytes), challenging human comprehension. Since no single mind can grasp an entire modern program, the goal is to organize programs to safely focus on one part at a time, minimizing the mental “juggling” required. This is achieved through:
- Dividing systems into subsystems (architecture).
- Creating independent objects and packages to separate concerns.
- Keeping routines short.
- Working at higher levels of abstraction (problem domain terms). Programmers who account for human cognitive limits write clearer, less error-prone code.
How to Attack Complexity
Overly complex or ineffective designs often stem from: 1) complex solutions to simple problems, 2) simple, incorrect solutions to complex problems, or 3) inappropriate complex solutions to complex problems. Since some complexity is inherent in real-world problems, the approach is twofold:
- Minimize the amount of essential complexity a brain deals with at once.
- Prevent accidental complexity from needlessly proliferating. Recognizing complexity management as the paramount technical goal simplifies many design decisions.
Desirable Characteristics of a Design
A high-quality design exhibits several characteristics, some of which may conflict, requiring careful tradeoffs:
- Minimal Complexity: The primary goal. Designs should be simple and easy to understand, allowing safe focus on one part without considering the whole. Avoid “clever” designs.
- Ease of Maintenance: Design with the future maintenance programmer in mind, making the system self-explanatory.
- Loose Coupling: Minimize connections between program parts. Use good class abstractions, encapsulation, and information hiding to reduce interdependencies, simplifying integration, testing, and maintenance.
- Extensibility: Enable system enhancement without disrupting its core structure, allowing changes to one part without affecting others.
- Reusability: Design system pieces to be usable in other systems.
- High Fan-In: Many classes use a given class, indicating good use of utility classes at lower levels.
- Low-to-Medium Fan-Out: A given class uses a low-to-medium number of other classes (ideally ⇐ 7). High fan-out suggests excessive complexity.
- Portability: Design for easy migration to other environments.
- Leanness: Design without extra or unnecessary parts (like Voltaire’s ideal book). Extra code incurs development, review, testing, and backward-compatibility costs.
- Stratification: Keep decomposition levels consistent, allowing the system to be viewed at any single level without “dipping into” others. For instance, creating an interface layer for old, messy code can compartmentalize complexity and simplify future refactoring.
- Standard Techniques: Rely on common, standardized approaches to make the system feel familiar and less intimidating to new developers.
Levels of Design
Software design occurs at multiple levels of detail, with some techniques applicable broadly and others more specifically.
- Software System (Level 1): The highest level, encompassing the entire system. Programmers should consider higher-level combinations of classes (subsystems/packages) before jumping straight to individual classes.
- Division into Subsystems or Packages (Level 2):
- Purpose: Identify major subsystems (e.g., database, UI, business rules, report engine) and define their interactions. Essential for projects lasting longer than a few weeks.
- Communication Rules: Crucially, restrict communication between subsystems to a “need to know” basis. Unrestricted communication (like every subsystem talking to every other) negates the benefits of separation, making changes difficult (e.g., in graphics, business rules, UI, data storage) and increasing complexity (“more hoses to disconnect”).
- Simplicity: Favor simple inter-subsystem relationships: one subsystem calling routines in another is simpler than containment or inheritance across subsystems.
- Acyclic Graph: A system-level diagram should ideally be an acyclic graph, preventing circular dependencies (e.g., A uses B, B uses C, C uses A).
- Common Subsystems: Recurring types include:
- Business Rules: Encodes laws, regulations, policies (e.g., tax rules, union contracts).
- User Interface: Isolates UI components for easier evolution (e.g., GUI, command line, menu, help).
- Database Access: Hides low-level database details, centralizes operations, reduces errors, and simplifies database schema changes.
- System Dependencies: Packages OS or hardware-specific calls (e.g., Windows API calls) to improve portability, requiring changes only in this interface subsystem when migrating to a new environment.
- Division into Classes (Level 3):
- Purpose: Identify all classes within subsystems and define their interfaces. This ensures subsystems are sufficiently detailed for class-level implementation. Required for projects longer than a few days.
- Classes vs. Objects: A class is the static blueprint (like a cookie cutter), while an object is a specific runtime instance (the cookie itself). (This book uses the terms largely interchangeably).
- Division into Routines (Level 4):
- Purpose: Break down each class into individual routines, including public routines defined by the class interface and private internal routines.
- Iteration: Fully defining routines often reveals insights that lead to changes in the class’s interface (Level 3).
- Scope: Often an individual programmer’s responsibility, this level of design is needed for projects longer than a few hours, even if done only mentally.
- Internal Routine Design (Level 5):
- Purpose: Detail the functionality of individual routines. This involves activities like writing pseudocode, algorithm selection, organizing code paragraphs, and writing programming-language code.
- Consistency: Always performed, though sometimes unconsciously and poorly rather than consciously and effectively.
Design Building Blocks: Heuristics
Software design, being nondeterministic, relies on the skillful application of heuristics—“rules of thumb” or “things to try that sometimes work”—to guide the design process and manage complexity (Software’s Primary Technical Imperative).
Find Real-World Objects
This is the classic object-oriented approach for identifying design alternatives, focusing on real-world and synthetic objects. The iterative steps are:
- Identify Objects and Their Attributes: Programs often model real-world entities (e.g.,
Employee,Client,Timecardin a billing system). Identify relevant characteristics for each (e.g., employee has name, title, billing rate). Real-world objects are a good starting point, though problem domain analysis might suggest better choices. - Determine What Can Be Done to Each Object: Define operations that modify or act upon each object (e.g., changing an employee’s title, a client’s address).
- Determine What Each Object Is Allowed to Do to Other Objects: Define relationships like containment (one object holds another) and inheritance (one object derives from another). For example, a
Timecardmight contain anEmployeeand aClient, and aBillmight containTimecards. - Determine the Parts of Each Object That Will Be Visible to Other Objects: Decide which data and methods will be
public(visible to all) andprivate(hidden). This is a crucial design decision. - Define Each Object’s Interfaces: Specify the formal, programming-language-level interfaces. This includes the public interface (exposed to all other objects) and the protected interface (exposed to derived objects via inheritance).
These steps are iterative, refining both the top-level system organization and the detailed design of each class.
Form Consistent Abstractions
Abstraction is the ability to interact with a concept while safely ignoring certain details, handling different levels of detail at different times. Examples include referring to a “house” instead of its individual components, or a “town” instead of individual houses.
- Benefits: Abstraction’s main benefit for complexity management is allowing you to ignore irrelevant details. Just as people constantly use abstractions in the real world (e.g., a “door” vs. its individual molecules), software systems benefit from them.
- Software Application: Base classes are abstractions focusing on commonalities. Good class, routine, or package interfaces provide abstractions by allowing focus on their functionality without needing to know internal workings.
- Warning: Building software at a “wood-fiber, varnish-molecule” level (i.e., too fine-grained, lacking higher-level abstractions) makes systems overly complex and intellectually unmanageable.
- Good Practice: Skilled programmers create consistent abstractions at routine, class, and package interface levels (like the “doorknob,” “door,” and “house” levels), supporting faster and safer programming.
Encapsulate Implementation Details
Encapsulation builds on abstraction: while abstraction allows viewing an object at a high level, encapsulation forbids looking at it at any other level of detail. It hides internal complexity, preventing external code from seeing implementation specifics (e.g., knowing a door exists and its state, but not its material or individual wood fibers). This directly helps manage complexity by preventing access to hidden details.
Inherit—When Inheritance Simplifies the Design
Inheritance addresses situations where objects are similar but have specific differences (e.g., full-time vs. part-time employees). You define a general type (base class) and specific types (derived classes) that inherit common characteristics. Operations applicable to the general type are handled generally; type-specific operations are handled differently.
- Benefit: Inheritance works synergistically with abstraction, allowing operations on an object (e.g.,
Open()aDoor) without needing to know its specific subtype until runtime. This capability is called polymorphism. - Caution: Inheritance is powerful but can be detrimental if used naively.
Hide Secrets (Information Hiding)
Information hiding is a foundational concept in both structured and object-oriented design, giving rise to “black boxes,” encapsulation, modularity, and abstraction. It involves “secrets”—design and implementation decisions hidden within one part of a program from the rest. David Parnas’s 1972 paper first popularized it, and even Fred Brooks, initially skeptical, later admitted Parnas was right about its value. Information hiding is a powerful heuristic for managing complexity.
Secrets and the Right to Privacy
Each class (or package/routine) hides specific design/construction decisions (secrets) from others. These secrets might be areas prone to change (e.g., file format, data type implementation) or areas needing isolation to limit error damage. A class’s job is to keep this information private; minor internal changes should not ripple beyond its interface.
- Visibility: A key design task is deciding which features are public (visible) and which remain secret (private). A class is like an iceberg: most of its complexity is hidden.
- Interface Iteration: Designing class interfaces is iterative; if an interface doesn’t stabilize, a different approach is needed.
An Example of Information Hiding
Consider assigning unique IDs (id) to objects.
- Bad Approach: Using a global
g_maxIdandid = ++g_maxIddirectly throughout the code exposes the ID creation mechanism. This makes future changes (e.g., reserving ID ranges, reusing IDs, adding assertions, multithreading) very difficult, requiring changes in many places. - Good Approach: Hide the ID creation logic within a routine, e.g.,
id = NewId(). Even ifNewId()initially just containsreturn (++g_maxId), future changes to ID allocation logic (e.g., reserving ranges, reusing old IDs) are confined to this single routine, not affecting hundreds of call sites. - Hiding Type: Further, hide the ID’s underlying type. Directly using
int idthroughout the program exposes the type, encouraging integer operations. Using atypedef(IdType id) or a simpleIdTypeclass hides the type, allowing changes (e.g., frominttostring) to be localized to thetypedefor class definition, drastically reducing code changes elsewhere.
Information hiding is valuable at all design levels, from using named constants to designing data types, classes, routines, and subsystems.
Two Categories of Secrets
Secrets in information hiding primarily serve two purposes:
- Hiding Complexity: This allows your brain to ignore intricate details unless directly relevant. Examples include complicated data types, file structures, boolean tests, and algorithms.
- Hiding Sources of Change: This localizes the impact of future modifications to specific parts of the code.
Barriers to Information Hiding
While genuinely impossible in rare cases, most barriers to information hiding are psychological or habitual:
- Excessive Distribution of Information: Spreading a single piece of information throughout the system prevents hiding it.
- Literals: Hardcoding values like
100multiple times instead of using a named constant (e.g.,MAX_EMPLOYEES). - User Interaction: Interleaving user interface logic throughout the system, making changes to the UI mode difficult. Centralizing UI interaction in a dedicated subsystem is better.
- Global Data: Directly accessing global data (e.g., a global array of employee data) spreads its implementation details. Using access routines to interact with global data hides these details.
- Literals: Hardcoding values like
- Circular Dependencies: When Class A calls Class B, and Class B calls Class A, it creates a dependency loop that hinders testing and information hiding. These should be avoided.
- Class Data Mistaken for Global Data: Conscientious programmers might avoid class data, mistaking it for problematic global data. However, class data is safer: access is restricted to a few routines within its class, and these routines are aware of each other’s operations on the data. This distinction blurs only if classes become excessively large.
- Perceived Performance Penalties: The fear that indirect data access (due to information hiding) incurs runtime performance penalties is often premature. Information hiding at the architectural level does not conflict with performance. At the coding level, it is better to design for modularity; performance bottlenecks should be identified through measurement, allowing for targeted optimization of specific classes/routines without affecting the whole system.
Value of Information Hiding
Information hiding is a theoretically sound technique that has proven its value in practice, making large programs four times easier to modify than those without it. It’s a cornerstone of both structured and object-oriented design.
- Heuristic Power: Information hiding offers unique heuristic power that inspires effective design solutions. For instance, an object-oriented designer might dismiss making a simple ID an object due to perceived overhead, choosing
intdirectly. However, an information-hiding mindset would prompt the question “What about the ID should be hidden?”, potentially leading to hiding its type behind atypedef(e.g.,IdType), a subtle but powerful change that localizes future modifications. This demonstrates how focusing on “what to hide” promotes design decisions that “object thinking” alone might miss. - Guiding Interface Design: It’s also crucial for designing a class’s public interface. Instead of simply exposing everything for convenience, asking “What does this class need to hide?” helps determine which functions or data can be made public without compromising secrets.
- Applies at All Levels: Information hiding guides design at all levels: using named constants (construction), creating good routine/parameter names (within classes), and guiding class/subsystem decomposition and interconnections (system level). The habit of asking “What should I hide?” can resolve many complex design issues.
Identify Areas Likely to Change
Great designers anticipate change. The goal is to isolate unstable areas so that changes are limited to a single routine, class, or package.
Follow these steps:
- Identify Likely Changes: Refer to requirements for a list of potential changes and their likelihood. If not available, consult the common volatile areas listed below.
- Separate Volatile Components: Compartmentalize each identified volatile component into its own class or into a class with other components likely to change simultaneously.
- Isolate Volatile Components: Design inter-class interfaces to be insensitive to potential changes. Interfaces should protect the class’s secrets, ensuring that changes within the class don’t affect outside users.
Here are common areas prone to change:
- Business Rules: Laws, regulations, policies, and procedures (e.g., tax structures, union contracts, insurance rates) frequently change. Isolate logic based on these rules using information hiding.
- Hardware Dependencies: Interfaces to hardware (screens, printers, disks, etc.) should be isolated in their own subsystem or class. This aids portability to new hardware environments and facilitates development with unstable/unavailable hardware via simulation.
- Input and Output (I/O): File formats and user-level input/output formats (field positioning, number of fields, sequence) are volatile. Examine all external interfaces for potential changes.
- Nonstandard Language Features: Proprietary language extensions or library routines that aren’t universally available should be hidden within a dedicated class. This allows replacement with custom code when porting to different environments.
- Difficult Design and Construction Areas: Areas prone to poor initial design or construction should be compartmentalized to minimize their impact if rework is needed.
- Status Variables: Variables indicating program state change frequently (e.g., evolving from a boolean to an enumerated type).
- Recommendation: Use enumerated types instead of booleans for status variables, as adding new states only requires recompilation, not extensive code revision.
- Recommendation: Use access routines to check status variables rather than direct variable access. This allows for more sophisticated state detection logic to be hidden within the routine, avoiding complex tests scattered throughout the code.
- Data-Size Constraints: Hardcoding array sizes (e.g.,
100) exposes unnecessary information. Use named constants (e.g.,MAX_EMPLOYEES) to hide this detail, localizing changes to a single definition.
Anticipating Different Degrees of Change
Design the system so that the scope of a change is proportional to its likelihood.
- Likely changes: Should be accommodated easily with minimal impact
- Extremely unlikely changes: Only these should have drastic consequences affecting more than one class.
- Cost vs. Likelihood: Consider the cost of anticipating a change. Plan more for changes that are likely and easy to accommodate than for unlikely and difficult ones.
A good technique to identify change-prone areas is to:
- Identify the minimal, core subset of the program useful to the user (this core is less likely to change).
- Define minimal increments to the system.
- Consider both functional changes (new features) and qualitative changes (e.g., making the program thread-safe, localizable). These increments and qualitative improvements represent potential changes; design them using information hiding principles. By first identifying the core, you can better see which components are true “add-ons” and then apply hiding principles accordingly.
Keep Coupling Loose
Coupling describes how interconnected classes or routines (“modules”) are. The objective is loose coupling: small, direct, visible, and flexible relationships between modules. This allows modules to be easily used by others, similar to easily connecting model railroad cars. The more independent modules are, the better.
Coupling Criteria
These criteria evaluate coupling
- Size: Refers to the number of connections. Smaller interfaces are better (e.g., a routine with one parameter is more loosely coupled than one with six; a class with four public methods is better than one with 37).
- Visibility: Connections should be obvious, not hidden. Passing data via parameter lists is good; modifying global data for another module is bad (sneaky).
- Flexibility: How easily connections can be changed. This is partly a product of other criteria. A module is flexible if it can be easily adapted for use by different callers without internal knowledge or kludges (e.g.,
LookupVacationBenefittaking specifichiringDateandjobClassificationdirectly, rather than anEmployeeobject if not all callers have a fullEmployeeobject). The more flexible a module, the more maintainable it is.
Kinds of Coupling
- Simple-Data-Parameter Coupling: Data passed between modules are primitive types via parameter lists. This is normal and acceptable.
- Simple-Object Coupling: A module instantiates an object. This is acceptable.
- Object-Parameter Coupling: One module requires another to pass it a specific object type. This is tighter than simple data parameter coupling because it imposes knowledge of the object type on the caller.
- Semantic Coupling (Most Insidious): One module relies on internal, non-syntactic knowledge of another. This is dangerous as changes in the used module can cause subtle, hard-to-debug failures in the using module. Examples include:
- Using control flags that imply internal knowledge of what the receiving module will do.
- Relying on global data modified by another module without explicit knowledge of when/how.
- Calling a routine without its required initialization because it’s known to be implicitly called internally.
- Passing a partially initialized object because only a subset of its methods are known to be used by the recipient.
- Casting a base object to a derived object based on assumed knowledge of the caller’s actual type.
The core purpose of loose coupling is to enhance abstraction, reduce overall program complexity, and allow developers to focus on one part of the code at a time. If a module requires knowledge of its internal workings or creates uncertainty, its ability to manage complexity is diminished. Classes and routines are intellectual tools to simplify work; if they don’t, they are failing.
Look for Common Design Patterns
Design patterns offer ready-made solutions to common software problems, much like using library code instead of writing custom sorts. They are valuable because most problems resemble past ones. Examples include Adapter, Factory Method, Singleton, and Strategy.
Patterns provide several benefits, primarily by helping to manage complexity:
- Reduce Complexity by Providing Ready-Made Abstractions: Using a pattern name (e.g., “Factory Method”) instantly communicates a rich set of interrelationships and protocols to other familiar programmers. A Factory Method, for instance, allows instantiation of derived classes without tracking each individual one, simplifying the calling code.
- Reduce Errors by Institutionalizing Details of Common Solutions: Patterns embody accumulated wisdom and corrections from past attempts at solving recurring design problems. Using them is akin to leveraging robust, pre-built solutions instead of creating potentially error-prone novel ones.
- Provide Heuristic Value by Suggesting Design Alternatives: Familiarity with patterns allows designers to quickly cycle through known solutions, asking “Which pattern fits my problem?” This is far easier than designing from scratch, and the resulting code is more understandable.
- Streamline Communication by Moving the Design Dialog to a Higher Level: Patterns facilitate more abstract and efficient design discussions. Saying “Should I use a Creator or a Factory Method?” communicates a lot quickly, assuming shared understanding, saving time that would be spent detailing each approach.
The familiarity of patterns to experienced programmers is a key part of their value, as it enables efficient communication.
Potential Pitfalls:
- Force-fitting: Shifting code significantly just to conform to a pattern can sometimes increase complexity rather than improve understanding.
- Feature-itis: Using a pattern merely out of a desire to try it out, rather than because it’s the most appropriate design solution for the problem at hand.
Despite these traps, design patterns are a powerful tool for complexity management.
Other Heuristics
Beyond the major design heuristics, several others offer valuable insights for managing complexity and improving design quality:
- Aim for Strong Cohesion: Cohesion measures how closely all elements (routines in a class, code in a routine) support a central, unified purpose. The goal is to maximize cohesion, as focused code is easier to understand and remember. While it’s a long-standing heuristic for routines, at the class level, it’s largely subsumed by the broader concept of well-defined abstractions.
- Build Hierarchies: Hierarchies are tiered information structures (like class hierarchies or routine-calling hierarchies) that organize concepts from general/abstract at the top to detailed/specialized at lower levels. Humans naturally use hierarchies to manage complex information (e.g., drawing a house outline before details). They help manage complexity by allowing focus on only the relevant level of detail at any given time, pushing less relevant details to another level.
- Formalize Class Contracts: Viewing a class’s interface as a contract specifies mutual promises: clients provide data with certain characteristics (preconditions), and the class performs operations within constraints (postconditions). In theory, this helps manage complexity by allowing the object to ignore non-contractual behavior.
- Assign Responsibilities: Thinking about what each object should be responsible for (similar to, but broader than, information hiding) can yield unique design insights.
- Design for Test: Designing a system to facilitate testing can lead to better designs. This might involve separating the user interface for independent testing or organizing subsystems to minimize dependencies. This often results in more formalized and generally beneficial class interfaces.
- Avoid Failure: Drawing on engineering insights (e.g., Henry Petroski’s work on bridge failures), software designers should actively consider potential failure modes rather than just replicating past successes. This proactive approach can prevent high-profile system failures, including security lapses.
- Choose Binding Time Consciously: Binding time is when a value is assigned to a variable. Early binding is simpler but less flexible. Consciously questioning whether to bind values earlier or later (e.g., hardcoding a table vs. reading it from a user at runtime) can provide good design insights into flexibility trade-offs.
- Make Central Points of Control: Adhering to “The Principle of One Right Place” means having a single, definitive location for any non-trivial piece of code or a likely maintenance change. Centralizing control (in classes, routines, constants, etc.) reduces complexity because fewer places to look make changes easier and safer.
- Consider Using Brute Force: Don’t underestimate brute-force solutions. A working brute-force solution is superior to an elegant one that fails to materialize or work correctly (e.g., a simple sequential search can be more reliable than a complex, buggy binary search).
- Draw a Diagram: Diagrams are powerful heuristic tools. A picture’s value lies in its ability to represent problems at a higher level of abstraction, allowing focus on generality when needed, rather than overwhelming detail.
- Keep Your Design Modular: Modularity aims to make each routine or class a “black box” – you know inputs and outputs, but not internal workings. A well-designed black box has a simple interface and predictable functionality. Modularity is related to information hiding and encapsulation, providing an alternative perspective that can lead to useful design insights.