📄 Page
1
(This page has no text content)
📄 Page
2
(This page has no text content)
📄 Page
3
Simple Object-Oriented Design 1. 1_It’s_all_about_managing_complexity 2. 2_Making_code_small 3. 3_Keeping_objects_consistent 4. 4_Managing_dependencies 5. 5_Designing_good_abstractions 6. 6_Handling_external_dependencies_and_infrastructure 7. 7_Achieving_modularization 8. 8_Being_pragmatic 9. index
📄 Page
4
1 It’s all about managing complexity This chapter covers Why software systems get more complex over time The different challenges in object-oriented design Why we should keep improving our design over time In 2010, I worked for this great internet company as part of a team responsible for billing. The company founder wrote the first version of the system 10 or 15 years before I joined. The logic was all within one or two very complex SQL Server stored procedures, which had thousands of lines of code each. It was time to refactor this existing billing infrastructure into something new. I can’t even count the number of hours we spent talking to our financial people so that we could create a design that would fit all their current and future needs. The great news is that we made it. We could add new products or financial rules in hours with our new implementation. The financial team was so happy with us. Feature requests that in the past would take weeks now take a couple of days. The quality was also much higher. Our design was highly testable, so we rarely introduced regression bugs. Even our most junior engineer could easily navigate through the code and feel confident enough to make critical changes alone. In a word, our new design was simple. I’ve been developing object-oriented software systems for 20 years and learned that, in an object-oriented system without a proper design, even simple things are just too hard. It doesn’t have to be like this. 1.1 Object-oriented design and the test-of-time Object-oriented programming is always a great choice when implementing complex software systems where flexibility and maintainability are requisites. However, solely picking an object-oriented language for your
📄 Page
5
project isn’t enough. You need to make good use of it. Luckily, we don’t have to invent best practices for object-oriented systems from scratch, as our community already has extensive knowledge. If you don’t know much about existing best practices or want to revisit them, this book is perfect for you, and you should read it cover to cover, including the code examples. If you are already a more senior engineer and aware of the existing best practices, this book will give you a different and pragmatic view of them, which will hopefully trigger interesting discussions in your mind. Here are some common questions that emerge in the minds of any developer building an information system using object-oriented language. Is this implementation simple enough, or should I propose a more elegant abstraction? This class goes through so many states over its life cycle. How can I ensure that it’s always in a consistent state? How should I model the interaction between my system and this external web app? Is it okay to make this class depend on this other class, or is that coupling bad? This book is called Simple Object-Oriented Design because simple object- oriented designs are always easier to maintain. The challenge is not only to develop a simple design but to keep it this way. I learned a lot from my good and bad decisions over the years, and that’s what I’ll share in this book: the set of patterns that have been helping me deliver easy-to-maintain and evolve object-oriented software systems. 1.2 Designing simple object-oriented systems "As software systems evolve, their complexity increases unless work is done to maintain or reduce it." Evolving software systems of any type isn’t that straightforward. We know that code tends to decay over time, requiring effort to maintain since the 1980s. This insight comes from professor Manny Lehman’s paper on the "laws of software evolution" (https://dl.acm.org/doi/10.1016/0164-1212%2879%2990022-0). And despite
📄 Page
6
40 years of progress, the maintainability of software systems remains a challenge. In essence, maintainability is the effort you need to complete tasks like modifying business rules, adding features, identifying bugs, and patching the system. Highly maintainable software enables developers to perform such tasks with reasonable effort, whereas low maintainability makes tasks too difficult, time-consuming, and bug-prone. Many factors affect maintainability, from overly complex code to dependency management, poorly designed abstractions, and bad modularization. Systems naturally become more complex over time, so continually adding code without considering its consequences on maintenance can quickly lead to a messy codebase. Consistently combatting complexity growth is crucial, even if it seems more time-consuming. And I know: it’s much more effort than simply "dumping code." But trust me, developers feel way worse when handling big balls of mud the entire day. You may have worked on codebases that are hard to maintain. I did. Doing anything in such systems takes a lot of time. You can’t find where to write your code, all code you write feels like it’s a workaround, you can’t write an automated test for it because the code is untestable, and you are always afraid something will go wrong because you never feel confident about changing it, and on it goes. What constitutes a simple object-oriented design? Based on my experience, it’s a design that presents the following six characteristics, also illustrated in figure 1.1: small units of code consistent objects proper dependency management good abstractions external dependencies and infrastructure properly handled well modularized Figure 1.1 Characteristics of a simple object-oriented design
📄 Page
7
These ideas may sound familiar to you. They are all popular in object- oriented systems. Let’s look at what I mean by each of them and what happens when we lose control, all in a nutshell. 1.2.1 Small units of code Implementing methods and classes that are simple in essence is a great way to start your journey toward maintainable object-oriented design. Consider a method that began as a few lines with a few conditional statements but grew
📄 Page
8
over time and currently has hundreds of lines and ifs inside of ifs. Maintaining such a method is just tricky. Interestingly, classes and methods usually start simple and manageable. But if we don’t work to keep them like this, they become hard to understand and maintain, like in figure 1.2. Complex code tends to result in bugs, as they are drawn to complex implementations that are difficult to understand. Complex code is also challenging to maintain, refactor, and test, as developers fear breaking something and struggle to identify all possible test cases. Figure 1.2 Simple code becomes complex over time and, consequently, very hard to maintain.
📄 Page
9
There are many ways to reduce the complexity of a class or method. For example, clear and expressive variable names help developers to understand better what’s going on. However, I’m arguing in this part of the book that the number one rule to keep classes and methods simple is to keep them small. A method shouldn’t be too long. A class shouldn’t have too many methods. Smaller units of code are always easier to maintain and evolve. 1.2.2 Consistent objects
📄 Page
10
It’s much easier to work on a system where you can trust that objects are always in a consistent state, and any attempt to make them inconsistent is denied. When consistency isn’t accounted for in the design, objects may hold invalid states, leading to bugs and maintainability issues. Consider a Basket class in an e-commerce system that tracks the products a person is buying and their final value. The total value must be updated whenever we add or remove a product from the basket. The basket should also reject invalid client requests, like adding a product -1 times or removing a product that isn’t there. In figure 1.3, the left side shows a protected basket, where items can only be added or removed by asking the basket itself. The basket is in complete control and ensures its consistency. On the right side, the unprotected basket allows unrestricted access to its contents. Given the lack of control, that basket can’t always ensure consistency. Figure 1.3 Two baskets, one that has control over the actions that happen on it, another one that doesn’t. Managing state and consistency is fundamental.
📄 Page
11
We’ll see that a good design ensures objects can’t ever be in an inconsistent state. Consistency mishandling can happen in many different ways, such as improper setter methods that bypass consistency checks or the lack of flexible validation mechanisms, which we’ll discuss in more detail later. 1.2.3 Proper dependency management In large-scale object-oriented systems, dependency management becomes critical to maintainability. In a system where the coupling is high and no one
📄 Page
12
cares about "which classes are coupled to which classes," any simple change may have unpredicted consequences. Figure 1.4 shows how the Basket class may be impacted by changes in any of its dependencies: DiscountRules, Product, Customer. Even a change in DiscountRepository, a transitive dependency, may impact Basket. If, say, class Product frequently changes, Basket is always at risk of having to change as well. Figure 1.4 Dependency management and change propagation
📄 Page
13
Simple object-oriented designs aim to minimize dependencies among classes. The less they depend on each other and the less they know about each other, the better. Good dependency management also ensures that our classes depend as much as possible on stable components that are less likely to change and, therefore, less likely to provoke cascading changes. 1.2.4 Good abstractions Simple code is always preferred, but it may not be sufficient for extensibility. Extending a class by adding more code at some point stops being effective and becomes a burden. Imagine implementing 30 or 40 different business rules in the same class or method. I illustrate that in figure 1.5. Note how the DiscountRule class, a class that’s responsible for applying different discounts in our e-commerce system, grows as new discount rules are introduced, making the class much harder to maintain. Figure 1.5 A class that has no abstractions grows in complexity indefinitely.
📄 Page
14
A good design provides developers with abstractions that help them evolve the system without making existing classes more complex. 1.2.5 External dependencies and infrastructure properly handled Simple object-oriented designs separate domain code that contains business logic from code required for communication with external dependencies. Figure 1.6 shows domain classes on the left and the classes that handle the
📄 Page
15
communication with other systems and infrastructure on the right. Figure 1.6 The architecture of a software system that separates infrastructure from domain code. Letting infrastructure details leak into your domain code may hinder your ability to make changes in the infrastructure. Imagine all the code to access the database is spread through the code base. Now, you must add a caching layer to speed up the application’s response time. You may have to change the code everywhere for that to happen.
📄 Page
16
The challenge here lies in abstracting the irrelevant aspects of the infrastructure or external while leveraging valuable features that are provided by your infrastructure. For instance, if you are using a relational database like Postgres, you may want to completely hide its presence from the domain code but still be able to use its unique features that enhance productivity or performance. Why do you call it infrastructure? I use "infrastructure" to refer to any dependency on external systems and resources such as web services, databases, third-party APIs, and anything beyond your system’s border. Whenever you have a dependency like that, you must write code connecting your system to the external one. We’ll focus on writing this "glue code" flexibly so it doesn’t harm the rest of your design in that chapter. 1.2.6 Well modularized As software systems grow, fitting everything into a single component or module is challenging. Simple object-oriented designs divide large systems into independent components that interact to achieve a common goal. Dividing systems into smaller components makes them easier to maintain and understand. It also helps different teams work on separate components without conflicts. Smaller components are more manageable and testable. Consider a software system with three domains: Invoice, Billing, and Delivery. These domains must work together, with Invoice and Delivery requiring information from Billing. Figure 1.7 shows a system without modules on the left, where classes from different domains mix freely. As complexity increases, this becomes unmanageable. The right side of the figure shows the same system divided into modules—Billing, Invoice, and Delivery. Modules interact through interfaces, ensuring clients only use what’s needed without understanding the entire domain. Figure 1.7 Two software systems with different modularization approaches.
📄 Page
17
It’s not easy to identify the right level of granularity for a module or what its public interface should look like, which we’ll discuss later. 1.3 Simple design as a day-to-day activity As I said before, creating a simple design isn’t usually the most complex challenge, but keeping it simple as the system evolves is. We must keep improving and simplifying our designs as we learn more about the system. For that to happen, we must transform design into an everyday activity.
📄 Page
18
1.3.1 Reducing complexity is similar to personal hygiene Constantly working towards simplifying the design can be compared to brushing your teeth. While not particularly exciting, it’s necessary to avoid discomfort and costly problems in the future. Similarly, investing a little time in code maintenance daily helps prevent more significant issues down the line. 1.3.2 Complexity might be necessary but should not be permanent At times, a degree of complexity is needed to uncover a simpler and more elegant solution to a problem. Many believe complexity can’t be sidestepped. Starting with a complex solution isn’t an issue; the problem lies in maintaining complexity indefinitely. Once you identify a simpler solution, it’s time to plan the refactoring. 1.3.3 Consistently addressing complexity is cost-effective Regularly addressing complexity keeps both the time and cost associated with it within reasonable limits. Delaying complexity management can result in significantly higher expenses and make refactoring more difficult and time-consuming. The "technical debt" metaphor helps us understand this. Coined by Ward Cunningham, the idea is to see coding issues as financial debt. The additional effort required for maintenance due to past poor decisions represents interest on this debt. This concept relates closely to the book’s focus; complexity escalates if code structure isn’t improved, leading to excessive interest payments. I’ve been to code bases where everyone knew that parts of it were very complex and challenging to maintain, but no one dared to refactor it. Trust me, you don’t want to get there. 1.3.4 High-quality code promotes good practices
📄 Page
19
When developers work with well-structured code with proper abstractions, straightforward methods, and comprehensive tests, they are more likely to maintain its quality. Conversely, messy code often leads to further disorganization and degradation in quality. This concept is similar to the broken-window theory, which explains how maintaining orderly environments can prevent further disorder. 1.3.5 Controlling complexity isn’t as difficult as it seems The key to ensuring that the complexity doesn’t grow out of hand is recognizing the signs as early as possible and addressing them early. With experience and knowledge, developers can detect and resolve most issues in the initial stages of development. Issues that are detected and tackled early enough are cheaper and faster to fix. 1.3.6 Keep the design simple is a developer’s responsibility Creating high-quality software systems that are easy to evolve and maintain can be challenging but necessary. As developers, managing complexity is part of our job and contributes to more efficient and sustainable software systems. Striking the right balance between manageable complexity and overwhelming chaos is challenging. Starting with complex abstraction might prevent issues, but it adds system complexity. A more straightforward method with two if statements is easier to understand than a convoluted interface. On the other hand, simple code isn’t enough at some point anymore. Striving for extensibility in every code piece would create chaos. It’s our job to find the right balance between simplicity and complexity. 1.3.7 Good enough designs John Ousterhout, in "A Philosophy of Software Design" (https://web.stanford.edu/~ouster/cgi-bin/book.php), says that it takes him at least three rewrites to get to the best design for a given problem. I couldn’t agree more.
📄 Page
20
Often, the most effective designs emerge after several iterations. In many cases, it’s more practical to focus on creating "good enough designs" that are easily understood, maintained, and evolved rather than striving for perfection from the outset. Again, the key is to identify when simple isn’t enough anymore. 1.4 A short dive into the architecture of an information system Let me define a few terms before discussing the different patterns to help you keep your design simple. We can discuss object-oriented design from many different angles. If you are building a framework that’s supposed to be highly generic, you may have different concerns compared to someone developing an enterprise system. I’ll focus on object-oriented design for information or enterprise systems in this book. As I defined before, an information system helps us organize information. Think of the back office of an online store, or a financial system that handles payments and customer billing, or an e-learning system that takes care of all students in a university. Figure 1.8 illustrates what usually happens behind an information system. These systems are often characterized by: Having a front-end that displays all the information to the user. This front-end is often implemented as a web page. Developers can use many technologies to build modern front-ends, such as React, Angular, VueJS, or even plain vanilla JavaScript, CSS, and HTML. In this book, we’ll not focus on the design of the front-end, but on the back-end. Having a back-end that handles the requests from the front-end. The back-end is the place where most, if not all, business logic lives. The back-end may communicate with other software systems to achieve its tasks, such as external or internal web services. Having a database that stores all the information. Back-end systems are strongly database-centric. This means most back-end actions involve retrieving, inserting, updating, or deleting information from the database.