A Philosophy of Software Design
The Complexity Problem
- Core problem: Recognize that complexity is the greatest impediment to software development
- Complexity definition: Make note that it’s anything that makes software hard to understand or modify
- Symptoms of complexity: Watch for change amplification, cognitive load, and unknown unknowns
- Strategic vs. tactical programming: Choose strategic (long-term) over tactical (quick-fix) approach
- Design principles: Focus on managing complexity as the fundamental goal of software design
- Working code isn’t enough: Remember that how you write code matters as much as whether it works
- Key insight: Invest time upfront in good design to save much more time later
The Nature of Complexity
- Definition: Identify complexity as anything that makes software hard to understand or modify
- Causes of complexity:
- Eliminate dependencies between code elements
- Reduce obscurity and unclear code
- Complexity symptoms:
- Change amplification: When a small change requires modifications in many places
- Cognitive load: Mental effort needed to complete a task
- Unknown unknowns: Unclear what needs to be modified or how
- Incremental nature: Recognize that complexity builds up in small increments over time
- Prevention approach: Take action on complexity as soon as you notice it
- Measurement challenge: Acknowledge that complexity is subjective but still recognizable
Working Code Isn’t Enough
- Beyond functionality: Create code that’s simple, obvious, and maintainable
- Technical debt: Avoid taking shortcuts that will cost more to fix later
- Strategic programming: Invest time upfront to produce clean, well-designed code
- Continuous design: Improve design with each modification
- Incremental investment: Make small improvements regularly rather than massive rewrites
- Learning opportunity: Use each project to become a better programmer
- Cost-benefit mindset: Remember that good design ultimately saves more time than it costs
Modules Should Be Deep
- Module definition: Think of a module as any unit of code with an interface (functions, classes, etc.)
- Interface vs. implementation: Separate what a module does from how it does it
- Deep modules: Create modules with powerful functionality but simple interfaces
- Shallow modules: Avoid modules whose interface complexity is similar to implementation complexity
- Abstraction quality: Judge abstractions by how much functionality they provide and how simple their interface is
- Information hiding: Conceal implementation details to allow independent modification
- Unix philosophy misapplication: Don’t create dozens of tiny, shallow modules
- Information hiding principle: Encapsulate design decisions that are likely to change
- Benefits: Design for independent modification of components
- Implementation details: Keep them private and flexible where possible
- Information leakage: Prevent implementation details from being exposed in APIs
- Temporal decomposition: Avoid designing classes around order of operations
- Leakage detection: Watch for situations where changing one module requires changes to others
- Pass-through methods: Eliminate methods that just pass calls to another object
- Example red flags: Classes named after actions rather than entities, excessive getters/setters
General-Purpose Modules are Deeper
- Generality principle: Design modules for broad, not narrow, use cases
- Over-specialization: Avoid creating different classes for slightly different behaviors
- Configuration parameters: Use them to make modules more flexible without complicating interfaces
- Goldilocks rule: Make modules slightly more general than their current use requires
- Example: Design a file class that handles different file types rather than a specialized class per type
- Default values: Provide sensible defaults for configuration parameters
- Warning: Don’t over-generalize when future needs are highly uncertain
Different Layer, Different Abstraction
- Abstraction layers: Make each layer represent a distinct abstraction
- Layer violation: Avoid exposing lower-level details in higher-level abstractions
- Pass-through methods: Eliminate methods that merely forward to another class
- Class hierarchy design: Create meaningful differences between parent and child classes
- Decoration pattern overuse: Watch for decorators that don’t add real value
- Interface duplication: Don’t repeat the same interface across layers
- Dispatching: Consider adding a new layer rather than cluttering existing ones with dispatch code
- Red flag: Beware when same abstractions appear at multiple layers
Pull Complexity Downwards
- Complexity placement: Push complexity down into lower-level modules, not up to callers
- User convenience: Make APIs simple at the expense of implementation complexity
- Default values: Set sensible defaults rather than requiring configuration
- Special case handling: Handle edge cases internally instead of forcing callers to handle them
- Interface design: Judge interfaces by how easy they are to use, not how easy they are to implement
- Implementation burden: Take on complexity in implementation to simplify interfaces
- Example: Make a string class handle empty strings gracefully instead of requiring callers to check
Better Together or Better Apart?
- Separation decision: Determine whether to divide or combine pieces of code
- Bring together if:
- They share information
- They’re used together
- They overlap conceptually
- It’s hard to understand one without the other
- Separate if:
- They’re unrelated
- It simplifies the interface
- It reduces dependencies
- General rule: Err on the side of bringing together for new systems
- Subsystem design: Make subsystems self-contained with minimal external dependencies
- Function length: Judge functions by clarity and abstraction level, not by lines of code
- Comment hint: If you need comments to separate regions in a method, consider extracting those regions
Define Errors Out of Existence
- Exception minimization: Design interfaces to eliminate exceptional conditions
- Disguising exceptions: Handle special cases automatically when possible
- Exception reduction: Reduce the number of places where errors must be handled
- Crash-only software: Design systems to recover automatically after crashes
- Mask exceptions: Turn exceptions into normal cases (e.g., creating a file if it doesn’t exist)
- Exception aggregation: Handle many exceptions with a single piece of code
- Just crash: For truly exceptional conditions, don’t try to recover in complex ways
Design it Twice
- Multiple designs: Create at least two different designs for complex problems
- Contrasting approaches: Explore fundamentally different approaches, not variations
- Design comparison: Evaluate strengths and weaknesses of each approach
- Early exploration: Do this before investing heavily in any implementation
- Major operations: Focus your comparison on how each design handles common operations
- Feature list: Compare designs against a specific list of features and requirements
- Design documentation: Document the alternatives considered and the reasoning for choices
- Value of comments: Use comments to capture information not obvious from the code
- Combat excuses:
- “Good code is self-documenting” – Not entirely true; code can’t explain why
- “I don’t have time” – Comments save time over the life of the project
- “Comments get out of date” – Keep them close to code they describe
- “Comments are worthless” – Only bad comments are worthless
- Comments’ purpose: Reduce cognitive load and improve maintainability
- Design documentation: Record major design decisions and their rationales
- Future investment: Write comments as an investment in future productivity
- Comment content: Explain things that aren’t obvious from the code alone
- Avoid redundancy: Don’t restate what’s already clear from the code
- Higher-level information: Document design decisions, rationales, and trade-offs
- Abstractions: Explain what the abstraction represents in the problem domain
- Preconditions: Document required states before method calls
- Postconditions: Document guaranteed states after method returns
- Interface comments: Focus on what the module does, not how
- Implementation comments: Explain tricky, non-obvious code segments
- Comment placement: Put comments where they’ll be seen when needed
Choosing Names
- Name clarity: Pick names that reflect what the thing represents
- Name precision: Choose names that are precise and unambiguous
- Name consistency: Use similar naming patterns for similar things
- Naming process: Iterate on names until they’re clear and descriptive
- Method names: Include both what it does and what it returns
- Avoid abbreviations: Prefer clarity over brevity (except for standard abbreviations)
- Name length: Make it proportional to scope (smaller scope = shorter name)
- Connotations: Consider what a name implies or suggests
- Implementation details: Keep them out of interface names
- Upfront commenting: Write interface comments before implementation
- Design thinking: Use comment writing to clarify your design thoughts
- Red flags: If a method is hard to describe, it’s probably a poor abstraction
- Comment discipline: Comments first encourages better design and consistent documentation
- Interface clarity: Clear comments lead to clearer interfaces
- Implementation guidance: Well-written comments guide implementation
- Documentation drift prevention: Update comments during implementation if design changes
- Example workflow: Write class comment, then method comments, then implementation
Modifying Existing Code
- Strategic approach: Maintain and improve design with each modification
- Cognitive load: Minimize it for the next developer
- Consistency: Keep stylistic consistency with surrounding code
- Improvement opportunities: Look for chances to improve design during modifications
- Tactical traps: Avoid quick hacks that worsen design
- Comments: Update them with code changes
- Boy Scout rule: Leave code cleaner than you found it
- Legacy improvement: Gradually improve legacy systems through disciplined modifications
Consistency
- Consistency importance: Write code that matches patterns and conventions
- Areas for consistency:
- Names
- Coding style
- Interfaces
- Design patterns
- Invariants
- Documentation: Make deviations from consistency obvious and documented
- Team standards: Establish and follow team consistency guidelines
- Tools: Use automated formatting and linting tools to enforce consistency
- Benefits: Consistent code is easier to read, understand, and modify
- Adaptability: Allow consistency rules to evolve as needed
Code Should be Obvious
- Obviousness principle: Write code whose behavior is obvious to a new reader
- Cognitive load reduction: Make code predictable and follow conventions
- Obscurity causes:
- Event-driven programming
- Generic containers
- Different meanings for the same variable
- Inconsistency
- External knowledge: Minimize required external context to understand code
- Surprising behavior: Document any code with non-obvious behavior
- Readers’ perspective: Consider how your code looks to someone unfamiliar with it
- Test of obviousness: Can someone understand your code after a quick skim?
Software Trends
- Agile development: Focus on delivering working software but don’t neglect design
- Test-driven development: Use tests to improve design, not just verify correctness
- Design patterns: Use them to communicate, but don’t force them where they don’t fit
- Object-oriented programming: Use it for its abstraction benefits, not dogmatically
- Functional programming: Recognize its benefits for managing state
- Inheritance overuse: Prefer composition in many cases
- Microservices: Consider the complexity trade-offs versus monolithic designs
- Trend evaluation: Judge trends by how they help manage complexity
- Design vs. performance: Optimize designs for simplicity first, performance second
- Measurement before optimization: Profile before making “optimizations”
- Simplicity and performance: Often, simpler designs perform better
- Performance-driven design: Redesign when performance issues are fundamental
- Common bottlenecks: Look for unnecessary object creation and data copying
- System boundaries: Watch for performance issues at API boundaries
- Optimization impact: Consider how optimizations affect code clarity
- End-to-end principle: Optimize complete operations, not individual components
Conclusion
- Complexity reduction: Make it your primary design goal
- Deep modules: Create modules with simple interfaces but powerful functionality
- Information hiding: Conceal implementation details that are likely to change
- General-purpose interfaces: Design for flexibility and reuse
- Layering: Maintain distinct abstraction levels in your system
- Pull complexity downward: Simplify interfaces by handling complexity internally
- Strategic programming: Invest in good design consistently over time
- Continuous improvement: Apply these principles incrementally with each change
- Experience building: Develop your design intuition through practice and reflection
Key Takeaways
- Complexity is the enemy: Design software primarily to reduce complexity
- Deep modules: Create components with simple interfaces but powerful functionality
- Strategic programming: Invest time upfront to save more time later
- Information hiding: Conceal implementation details to allow independent modification
- Pull complexity down: Make interfaces simple even if it means more complex implementations
- Comments matter: Write them to explain things not obvious from the code
- Design it twice: Consider multiple approaches before implementing
- Consistency: Maintain consistent patterns and conventions
- Incremental improvement: Apply good design principles with every code change
- Obviousness: Write code whose behavior is clear to new readers