# 第 10 章 柔性设计
Ten. Supple Design
Image The ultimate purpose of software is to serve users. But first, that same software has to serve developers. This is especially true in a process that emphasizes refactoring. As a program evolves, developers will rearrange and rewrite every part. They will integrate the domain objects into the application and with new domain objects. Even years later, maintenance programmers will be changing and extending the code. People have to work with this stuff. But will they want to?
When software with complex behavior lacks a good design, it becomes hard to refactor or combine elements. Duplication starts to appear as soon as a developer isn’t confident of predicting the full implications of a computation. Duplication is forced when design elements are monolithic, so that the parts cannot be recombined. Classes and methods can be broken down for better reuse, but it gets hard to keep track of what all the little parts do. When software doesn’t have a clean design, developers dread even looking at the existing mess, much less making a change that could aggravate the tangle or break something through an unforeseen dependency. In any but the smallest systems, this fragility places a ceiling on the richness of behavior it is feasible to build. It stops refactoring and iterative refinement.
To have a project accelerate as development proceeds—rather than get weighed down by its own legacy—demands a design that is a pleasure to work with, inviting to change. A supple design.
Supple design is the complement to deep modeling. Once you’ve dug out implicit concepts and made them explicit, you have the raw material. Through the iterative cycle, you hammer that material into a useful shape, cultivating a model that simply and clearly captures the key concerns, and shaping a design that allows a client developer to really put that model to work. Development of the design and code leads to insight that refines model concepts. Round and round—we’re back to the iterative cycle and refactoring toward deeper insight. But what kind of design are you trying to arrive at? What kind of experiments should you try along the way? That is what this chapter is about.
A lot of overengineering has been justified in the name of flexibility. But more often than not, excessive layers of abstraction and indirection get in the way. Look at the design of software that really empowers the people who handle it; you will usually see something simple. Simple is not easy. To create elements that can be assembled into elaborate systems and still be understandable, a dedication to MODEL-DRIVEN DESIGN has to be joined with a moderately rigorous design style. It may well require relatively sophisticated design skill to create or to use.
Developers play two roles, each of which must be served by the design. The same person might well play both roles—even switch back and forth in minutes—but the relationship to the code is different nonetheless. One role is the developer of a client, who weaves the domain objects into the application code or other domain layer code, utilizing capabilities of the design. A supple design reveals a deep underlying model that makes its potential clear. The client developer can flexibly use a minimal set of loosely coupled concepts to express a range of scenarios in the domain. Design elements fit together in a natural way with a result that is predictable, clearly characterized, and robust.
Equally important, the design must serve the developer working to change it. To be open to change, a design must be easy to understand, revealing that same underlying model that the client developer is drawing on. It must follow the contours of a deep model of the domain, so most changes bend the design at flexible points. The effects of its code must be transparently obvious, so the consequences of a change will be easy to anticipate.
Early versions of a design are usually stiff. Many never acquire any suppleness in the time frame or budget of the project. I’ve never seen a large program that had this quality throughout. But when complexity is holding back progress, honing the most crucial, intricate parts to a supple design makes the difference between getting sucked down into legacy maintenance and punching through the complexity ceiling.
There is no formula for designing software like this, but I have culled a set of patterns that, in my experience, tend to lend suppleness to a design when they fit. These patterns and examples should give a feel for what a supple design is like and the kind of thinking that goes into it.
Image Figure 10.1. Some patterns that contribute to supple design
INTENTION-REVEALING INTERFACES In domain-driven design, we want to think about meaningful domain logic. Code that produces the effect of a rule without explicitly stating the rule forces us to think of step-by-step software procedures. The same applies to a calculation that just results from running some code, but isn’t explicit. Without a clear connection to the model, it is difficult to understand the effect of the code or anticipate the effect of a change. The previous chapter delved into modeling rules and calculations explicitly. Implementing such objects requires a lot of understanding of the gritty details of the calculation or the fine print of the rule. The beauty of objects is their ability to encapsulate all that, so that client code is simple and can be interpreted in terms of higher-level concepts.
But if the interface doesn’t tell the client developer what he needs to know in order to use the object effectively, he will have to dig into the internals to understand the details anyway. A reader of the client code will have to do the same. Then most of the value of the encapsulation is lost. We are always fighting cognitive overload: If the client developer’s mind is flooded with detail about how a component does its job, his mind isn’t clear to work out the intricacies of the client design. This is true even when the same person is playing both roles, developing and using his own code, because even if he doesn’t have to learn those details, there is a limit to how many factors he can consider at once.
If a developer must consider the implementation of a component in order to use it, the value of encapsulation is lost. If someone other than the original developer must infer the purpose of an object or operation based on its implementation, that new developer may infer a purpose that the operation or class fulfills only by chance. If that was not the intent, the code may work for the moment, but the conceptual basis of the design will have been corrupted, and the two developers will be working at cross-purposes.
To obtain the value of explicitly modeling a concept in the form of a class or method, we must give these program elements names that reflect those concepts. The names of classes and methods are great opportunities for improving communication between developers, and for improving the abstraction of the system.
Kent Beck wrote of making method names communicate their purpose with an INTENTION-REVEALING SELECTOR (Beck 1997). All public elements of a design together make up its interface, and the name of each of those elements presents an opportunity to reveal the intention of the design. Type names, method names, and argument names all combine to form an INTENTION-REVEALING INTERFACE.
Therefore:
Name classes and operations to describe their effect and purpose, without reference to the means by which they do what they promise. This relieves the client developer of the need to understand the internals. These names should conform to the UBIQUITOUS LANGUAGE so that team members can quickly infer their meaning. Write a test for a behavior before creating it, to force your thinking into client developer mode.
All the tricky mechanism should be encapsulated behind abstract interfaces that speak in terms of intentions, rather than means.
In the public interfaces of the domain, state relationships and rules, but not how they are enforced; describe events and actions, but not how they are carried out; formulate the equation but not the numerical method to solve it. Pose the question, but don’t present the means by which the answer shall be found.
Example: Refactoring: A Paint-Mixing Application A program for paint stores can show a customer the result of mixing standard paints. Here is the initial design, which has a single domain class.
Image Figure 10.2
The only way to even guess what the paint(Paint) method does is to read the code.
public void paint(Paint paint) { v = v + paint.getV(); //After mixing, volume is summed // Omitted many lines of complicated color mixing logic // ending with the assignment of new r, b, and y values. }
OK, so it looks like this method combines two Paints together, the result having a larger volume and a mixed color.
To shift our perspective, let’s write a test for this method. (This code is based on the JUnit test framework.)
public void testPaint() { // Create a pure yellow paint with volume=100 Paint yellow = new Paint(100.0, 0, 50, 0); // Create a pure blue paint with volume=100 Paint blue = new Paint(100.0, 0, 0, 50);
// Mix the blue into the yellow
yellow.paint(blue);
// Result should be volume of 200.0 of green paint
assertEquals(200.0, yellow.getV(), 0.01);
assertEquals(25, yellow.getB());
assertEquals(25, yellow.getY());
assertEquals(0, yellow.getR());
}
The passing test is the starting point. It is unsatisfying at this point because the code in the test doesn’t tell us what it is doing. Let’s rewrite the test to reflect the way we would like to use the Paint objects if we were writing a client application. Initially, this test will fail. In fact, it won’t even compile. We are writing it to explore the interface design of the Paint object from the client developer’s point of view.
public void testPaint() { // Start with a pure yellow paint with volume=100 Paint ourPaint = new Paint(100.0, 0, 50, 0); // Take a pure blue paint with volume=100 Paint blue = new Paint(100.0, 0, 0, 50);
// Mix the blue into the yellow
ourPaint.mixIn(blue);
// Result should be volume of 200.0 of green paint
assertEquals(200.0, ourPaint.getVolume(), 0.01);
assertEquals(25, ourPaint.getBlue());
assertEquals(25, ourPaint.getYellow());
assertEquals(0, ourPaint.getRed());
}
We should take our time to write a test that reflects the way we would like to talk to these objects. After that, we refactor the Paint class to make the test pass.
Image Figure 10.3
The new method name may not tell the reader everything about the effect of “mixing in” another Paint (for that we’ll need ASSERTIONS, coming up in a few pages). But it will clue the reader in enough to get started using the class, especially with the example the test provides. And it will allow the reader of the client code to interpret the client’s intent. In the next few examples in this chapter, we’ll refactor this class again to make it even clearer.
Image Image Image Entire subdomains can be carved off into separate modules and encapsulated behind INTENTION-REVEALING INTERFACES. Using such whittling to focus a project and manage the complexity of a large system will be discussed more in Chapter 15, “Distillation,” with COHESIVE MECHANISMS and GENERIC SUBDOMAINS.
But in the next two patterns, we’ll set out to make the consequences of using a method very predictable. Complex logic can be done safely in SIDE-EFFECT-FREE FUNCTIONS. Methods that change system state can be characterized with ASSERTIONS.
SIDE-EFFECT-FREE FUNCTIONS Operations can be broadly divided into two categories, commands and queries. Queries obtain information from the system, possibly by simply accessing data in a variable, possibly performing a calculation based on that data. Commands (also known as modifiers) are operations that affect some change to the systems (for a simple example, by setting a variable). In standard English, the term side effect implies an unintended consequence, but in computer science, it means any effect on the state of the system. For our purposes, let’s narrow that meaning to any change in the state of the system that will affect future operations.
Why was the term side effect adopted and applied to quite intentional changes affected by operations? I assume this was based on experience with complex systems. Most operations call on other operations, and those called invoke still other operations. As soon as this arbitrarily deep nesting is involved, it becomes very hard to anticipate all the consequences of invoking an operation. The developer of the client may not have intended the effects of the second-tier and third-tier operations—they’ve become side effects in every sense of the phrase. Elements of a complex design interact in other ways that are likely to produce the same unpredictability. The use of the term side effect underlines the inevitability of that interaction.
Interactions of multiple rules or compositions of calculations become extremely difficult to predict. The developer calling an operation must understand its implementation and the implementation of all its delegations in order to anticipate the result. The usefulness of any abstraction of interfaces is limited if the developers are forced to pierce the veil. Without safely predictable abstractions, the developers must limit the combinatory explosion, placing a low ceiling on the richness of behavior that is feasible to build.
Operations that return results without producing side effects are called functions. A function can be called multiple times and return the same value each time. A function can call on other functions without worrying about the depth of nesting. Functions are much easier to test than operations that have side effects. For these reasons, functions lower risk.
Obviously, you can’t avoid commands in most software systems, but the problem can be mitigated in two ways. First, you can keep the commands and queries strictly segregated in different operations. Ensure that the methods that cause changes do not return domain data and are kept as simple as possible. Perform all queries and calculations in methods that cause no observable side effects (Meyer 1988).
Second, there are often alternative models and designs that do not call for an existing object to be modified at all. Instead, a new VALUE OBJECT, representing the result of the computation, is created and returned. This is a common technique, which will be illustrated in the example that follows. A VALUE OBJECT can be created in answer to a query, handed off, and forgotten—unlike an ENTITY, whose life cycle is carefully regulated.
VALUE OBJECTS are immutable, which implies that, apart from initializers called only during creation, all their operations are functions. VALUE OBJECTS, like functions, are safer to use and easier to test. An operation that mixes logic or calculations with state change should be refactored into two separate operations (Fowler 1999, p. 279). But by definition, this segregation of side effects into simple command methods only applies to ENTITIES. After completing the refactoring to separate modification from querying, consider a second refactoring to move the responsibility for the complex calculations into a VALUE OBJECT. The side effect often can be completely eliminated by deriving a VALUE OBJECT instead of changing existing state, or by moving the entire responsibility into a VALUE OBJECT.
Therefore:
Place as much of the logic of the program as possible into functions, operations that return results with no observable side effects. Strictly segregate commands (methods that result in modifications to observable state) into very simple operations that do not return domain information. Further control side effects by moving complex logic into VALUE OBJECTS when a concept fitting the responsibility presents itself.
SIDE-EFFECT-FREE FUNCTIONS, especially in immutable VALUE OBJECTS, allow safe combination of operations. When a FUNCTION is presented through an INTENTION-REVEALING INTERFACE, a developer can use it without understanding the detail of its implementation.
Example: Refactoring the Paint-Mixing Application Again A program for paint stores can show a customer the result of mixing standard paints. Picking up where we left off in the last example, here is the single domain class.
Image Figure 10.4
public void mixIn(Paint other) { volume = volume.plus(other.getVolume()); // Many lines of complicated color-mixing logic // ending with the assignment of new red, blue, // and yellow values. }
Image Figure 10.5. The side effects of the mixIn() method
A lot is happening in the mixIn() method, but this design does follow the rule of separating modification from querying. One concern, which we’ll take up later, is that the volume of the paint 2 object, the argument of the mixIn() method, has been left in limbo. Paint 2’s volume is unchanged by the operation, which doesn’t seem quite logical in the context of this conceptual model. This was not a problem for the original developers because, as near as we can tell, they had no interest in the paint 2 object after the operation, but it is hard to anticipate the consequences of side effects or their absence. We’ll return to this question soon in the discussion of ASSERTIONS. For now, let’s look at color.
Color is an important concept in this domain. Let’s try the experiment of making it an explicit object. What should it be called? “Color” comes to mind first, but earlier knowledge crunching had already yielded the important insight that color mixing is different for paint than it is for the more familiar RGB light display. The name needs to reflect this.
Image Figure 10.6
Factoring out Pigment Color does communicate more than the earlier version, but the computation is the same, still in the mixIn() method. When we moved out the color data, we should have taken related behavior with it. Before we do, note that Pigment Color is a VALUE OBJECT. Therefore, it should be treated as immutable. When we mixed paint, the Paint object itself was changed. It was an ENTITY with an ongoing life story. In contrast, a Pigment Color representing a particular shade of yellow is always exactly that. Instead, mixing will result in a new Pigment Color object representing the new color.
Image Figure 10.7
public class PigmentColor {
public PigmentColor mixedWith(PigmentColor other, double ratio) { // Many lines of complicated color-mixing logic // ending with the creation of a new PigmentColor object // with appropriate new red, blue, and yellow values. } }
public class Paint {
public void mixIn(Paint other) { volume = volume + other.getVolume(); double ratio = other.getVolume() / volume; pigmentColor = pigmentColor.mixedWith(other.pigmentColor(), ratio); } }
Image Figure 10.8
Now the modification code in Paint is as simple as possible. The new Pigment Color class captures knowledge and communicates it explicitly, and it provides a SIDE-EFFECT-FREE FUNCTION whose result is easy to understand, easy to test, and safe to use or combine with other operations. Because it is so safe, the complex logic of color mixing is truly encapsulated. Developers using this class don’t have to understand the implementation.
Image Image Image ASSERTIONS Separating complex computations into SIDE-EFFECT-FREE FUNCTIONS cuts the problem down to size, but there is still a residue of commands on the ENTITIES that produce side effects, and anyone using them must understand their consequences. ASSERTIONS make side effects explicit and easier to deal with.
Image Image Image True, a command containing no complex computations may be fairly easy to interpret by inspection. But in a design where larger parts are built of smaller ones, a command may invoke other commands. The developer using the high-level command must understand the consequences of each underlying command. So much for encapsulation. And because object interfaces do not restrict side effects, two subclasses that implement the same interface can have different side effects. The developer using them will want to know which is which to anticipate the consequences. So much for abstraction and polymorphism.
When the side effects of operations are only defined implicitly by their implementation, designs with a lot of delegation become a tangle of cause and effect. The only way to understand a program is to trace execution through branching paths. The value of encapsulation is lost. The necessity of tracing concrete execution defeats abstraction.
We need a way of understanding the meaning of a design element and the consequences of executing an operation without delving into its internals. INTENTION-REVEALING INTERFACES carry us part of the way there, but informal suggestions of intentions are not always enough. The “design by contract” school goes the next step, making “assertions” about classes and methods that the developer guarantees will be true. This style is discussed in detail in Meyer 1988. Briefly, “post-conditions” describe the side effects of an operation, the guaranteed outcome of calling a method. “Preconditions” are like the fine print on the contract, the conditions that must be satisfied in order for the post-condition guarantee to hold. Class invariants make assertions about the state of an object at the end of any operation. Invariants can also be declared for entire AGGREGATES, rigorously defining integrity rules.
All these assertions describe state, not procedures, so they are easier to analyze. Class invariants help characterize the meaning of a class, and simplify the client developer’s job by making the objects more predictable. If you trust the guarantee of a post-condition, you don’t have to worry about how a method works. The effects of delegations should already be incorporated into the assertions.
Therefore:
State post-conditions of operations and invariants of classes and AGGREGATES. If ASSERTIONS cannot be coded directly in your programming language, write automated unit tests for them. Write them into documentation or diagrams where it fits the style of the project’s development process.
Seek models with coherent sets of concepts, which lead a developer to infer the intended ASSERTIONS, accelerating the learning curve and reducing the risk of contradictory code.
Even though many object-oriented languages don’t currently support ASSERTIONS directly, ASSERTIONS are still a powerful way of thinking about a design. Automated unit tests can partially compensate for the lack of language support. Because ASSERTIONS are all in terms of states, rather than procedures, they make tests easy to write. The test setup puts the preconditions in place; then, after execution, the test checks to see if the post-conditions hold.
Clearly stated invariants and pre- and post-conditions allow a developer to understand the consequences of using an operation or object. Theoretically, any noncontradictory set of assertions would work. But humans don’t just compile predicates in their heads. They will be extrapolating and interpolating the concepts of the model, so it is important to find models that make sense to people as well as satisfying the needs of the application.
Example: Back to Paint Mixing Recall that in the previous example I was concerned about the ambiguity of what happens to the argument of the mixIn(Paint) operation on the Paint class.
Image Figure 10.9
The receiver’s volume is increased by the amount of the argument’s volume. Drawing on our general understanding of physical paint, this mixing process should deplete the other paint by the same amount, draining it to zero volume, or eliminating it completely. The current implementation does not modify the argument, and modifying arguments is a particularly risky kind of side effect anyway.
To start on a solid footing, let’s state the post-condition of the mixIn() method as it is:
After p1.mixIn(p2):
p1.volume is increased by amount of p2.volume.
p2.volume is unchanged.
The trouble is, developers are going to make mistakes, because these properties don’t fit the concepts we have invited them to think about. The straightforward fix would be change the volume of the other paint to zero. Changing an argument is a bad practice, but it would be easy and intuitive. We could state an invariant:
Total volume of paint is unchanged by mixing.
But wait! While developers were pondering this option, they made a discovery. It turns out that there was a compelling reason the original designers made it this way. At the end, the program reports the list of unmixed paints that were added. After all, the ultimate purpose of this application is to help a user figure out which paints to put into a mixture.
So, to make the volume model logically consistent would make it unsuitable for its application requirements. There seems to be a dilemma. Are we stuck with documenting the weird post-condition and trying to compensate with good communication? Not everything in this world is intuitive, and sometimes that is the best answer. But in this case, the awkwardness seems to point to missing concepts. Let’s look for a new model.
We Can See Clearly Now As we search for a better model, we have significant advantages over the original designers, because of the knowledge crunching and refactoring to deeper insight that has happened in the interim. For example, we compute color using a SIDE-EFFECT-FREE FUNCTION on a VALUE OBJECT. This means we can repeat the calculation any time we need to. We should take advantage of that.
We seem to be giving Paint two different basic responsibilities. Let’s try splitting them.
Now there is only one command, mixIn(). It just adds an object to a collection, an effect apparent from an intuitive understanding of the model. All other operations are SIDE-EFFECT-FREE FUNCTIONS.
A test method confirming one of the ASSERTIONS listed in Figure 10.10 could look something like this (using the JUnit test framework):
public void testMixingVolume { PigmentColor yellow = new PigmentColor(0, 50, 0); PigmentColor blue = new PigmentColor(0, 0, 50);
StockPaint paint1 = new StockPaint(1.0, yellow); StockPaint paint2 = new StockPaint(1.5, blue); MixedPaint mix = new MixedPaint();
mix.mixIn(paint1); mix.mixIn(paint2); assertEquals(2.5, mix.getVolume(), 0.01); }
Image Figure 10.10
This model captures and communicates more of the domain. The invariants and post-conditions make common sense, which will make them easier to maintain and use.
Image Image Image The communicativeness of the INTENTION-REVEALING INTERFACES, combined with the predictability given by SIDE-EFFECT-FREE FUNCTIONS and ASSERTIONS, should make encapsulation and abstraction safe.
The next ingredient in recombinable elements is effective decomposition. . . .
CONCEPTUAL CONTOURS Sometimes people chop functionality fine to allow flexible combination. Sometimes they lump it large to encapsulate complexity. Sometimes they seek a consistent granularity, making all classes and operations to a similar scale. These are oversimplifications that don’t work well as general rules. But they are motivated by a basic set of problems.
When elements of a model or design are embedded in a monolithic construct, their functionality gets duplicated. The external interface doesn’t say everything a client might care about. Their meaning is hard to understand, because different concepts are mixed together.
On the other hand, breaking down classes and methods can pointlessly complicate the client, forcing client objects to understand how tiny pieces fit together. Worse, a concept can be lost completely. Half of a uranium atom is not uranium. And of course, it isn’t just grain size that counts, but just where the grain runs.
Cookbook rules don’t work. But there is a logical consistency deep in most domains, or else they would not be viable in their own sphere. This is not to say that domains are perfectly consistent, and certainly the ways people talk about them are not consistent. But there is rhyme and reason somewhere, or else modeling would be pointless. Because of this underlying consistency, when we find a model that resonates with some part of the domain, it is more likely to be consistent with other parts that we discover later. Sometimes the new discovery isn’t easy for the model to adapt to, in which case we refactor to deeper insight, and hope to conform to the next discovery.
This is one reason why repeated refactoring eventually leads to suppleness. The CONCEPTUAL CONTOURS emerge as the code is adapted to newly understood concepts or requirements.
The twin fundamentals of high cohesion and low coupling play a role in design at all scales, from individual methods up through classes and MODULES to large-scale structures (see Chapter 16). These two principles apply to concepts as much as to code. To avoid slipping into a mechanistic view of them, temper your technical thinking by frequently touching base with your intuition for the domain. With each decision, ask yourself, “Is this an expedient based on a particular set of relationships in the current model and code, or does it echo some contour of the underlying domain?”
Find the conceptually meaningful unit of functionality, and the resulting design will be both flexible and understandable. For example, if an “addition” of two objects has a coherent meaning in the domain, then implement methods at that level. Don’t break the add() into two steps. Don’t proceed to the next step within the same operation. On a slightly larger scale, each object should be a single complete concept, a “WHOLE VALUE.”1
By the same token, there are areas in any domain where detail isn’t interesting to the kind of people the software serves. The users of our hypothetical paint mixing application don’t add red pigment or blue pigment; they combine complete paints, which contain all three pigments. Clumping things that don’t need to be dissected or rearranged avoids clutter and makes it easier to see the elements that really are meant to recombine. If our users’ physical equipment allowed individual pigments to be added, the domain would be altered, and the individual pigments might be manipulated. A paint chemist would need still finer control, which would involve a whole other analysis, probably producing a much more detailed model of the makeup of paint than our abstracted pigment color that serves paint mixing. But it is simply irrelevant to anyone involved in the paint mixing application project.
Therefore:
Decompose design elements (operations, interfaces, classes, and AGGREGATES) into cohesive units, taking into consideration your intuition of the important divisions in the domain. Observe the axes of change and stability through successive refactorings and look for the underlying CONCEPTUAL CONTOURS that explain these shearing patterns. Align the model with the consistent aspects of the domain that make it a viable area of knowledge in the first place.
The goal is a simple set of interfaces that combine logically to make sensible statements in the UBIQUITOUS LANGUAGE, and without the distraction and maintenance burden of irrelevant options. This is typically an outcome of refactoring: it’s hard to produce up front. But it may never emerge from technically oriented refactoring; it emerges from refactoring toward deeper insight.
Even when the design follows CONCEPTUAL CONTOURS, there will need to be modifications and refactoring. When successive refactoring tends to be localized, not shaking multiple broad concepts of the model, it is an indicator of model fit. Encountering a requirement that forces extensive changes in the breakdown of the objects and methods is a message: Our understanding of the domain needs refinement. It presents an opportunity to deepen the model and make the design more supple.
Example: The CONTOURS of Accruals In Chapter 9, a loan tracking system was refactored based on deeper insight into accounting concepts:
Image Figure 10.11
The new model contained only one more object than the old one, yet the partitioning of responsibility had been greatly changed.
Schedules, which had been worked out through case logic in the Calculator classes, were exploded into discrete classes for different types of fees and interest. On the other hand, payments of fees and interest, previously kept separate, were lumped together.
Because of the resonance of the newly explicit concepts and the cohesiveness of the Accrual Schedule hierarchy, the developer believed that this model better follows some of the domain’s CONCEPTUAL CONTOURS.
Image Figure 10.12. This model accommodates adding new kinds of Accrual Schedules.
The one change the developer could confidently predict was the addition of new Accrual Schedules. Those requirements were already waiting in the wings. So in addition to making existing functionality clearer and simpler, she chose a model that would make it easy to introduce new schedules. But had she found a CONCEPTUAL CONTOUR that will help the domain design change and grow as the application and the business evolve? There can be no guarantees about how a design will handle unanticipated change, but she thought it had improved the odds.
An Unanticipated Change As the project proceeded, a requirement emerged for detailed rules for handling early and late payments. As she studied the problem, the developer was pleased to see that virtually the same rules applied to payments on interest and to payments on fees. This meant that the new model elements would connect naturally to the single Payment class.
Image Figure 10.13
The old design would have forced duplication between the two Payment History classes. (This difficulty might have triggered an insight that the Payment class should be shared, leading by another path to a similar model.) This ease of extension did not come because she anticipated the change. Nor did it come because she made a design so versatile it could accommodate any conceivable change. It happened because in the previous refactoring, the design was aligned with underlying concepts of the domain.
Image Image Image INTENTION-REVEALING INTERFACES allow clients to present objects as units of meaning rather than just mechanisms. SIDE-EFFECT-FREE FUNCTIONS and ASSERTIONS make it safe to use those units and make complex combinations. The emergence of CONCEPTUAL CONTOURS stabilizes parts of the model and also makes the units more intuitive to use and combine.
We can still run into conceptual overload when interdependencies force us to think about too many of these things at a time. . . .
STANDALONE CLASSES Interdependencies make models and designs hard to understand. They also make them hard to test and maintain. And interdependencies pile up easily.
Every association is, of course, a dependency, and understanding a class requires understanding what it is attached to. Those attached things will be attached to still more things, and they have to be understood too. The type of every argument of every method is also a dependency. So is every return value.
With one dependency, you have to think about two classes at the same time, and the nature of their relationship. With two dependencies, you have to think about each of the three classes, the nature of the class’s relationship to each of them, and any relationship they might have to each other. If they in turn have dependencies, you have to be wary of those also. With three dependencies . . . it snowballs.
Both MODULES and AGGREGATES are aimed at limiting the web of interdependencies. When a highly cohesive subdomain is carved out into a MODULE, a set of objects are decoupled from the rest of the system, so there are a finite number of interrelated concepts. But even a MODULE can be a lot to think about without an almost fanatical commitment to controlling dependencies within it.
Even within a MODULE, the difficulty of interpreting a design increases wildly as dependencies are added. This adds to mental overload, limiting the design complexity a developer can handle. Implicit concepts contribute to this load even more than explicit references.
Refined models are distilled until every remaining connection between concepts represents something fundamental to the meaning of those concepts. In an important subset, the number of dependencies can be reduced to zero, resulting in a class that can be fully understood all by itself, along with a few primitives and basic library concepts.
In every programming environment, a few basics are so pervasive that they are always in mind. For example, in Java development, primitives and a few standard libraries provide basics like numbers, strings, and collections. Practically speaking, “integers” don’t add to the intellectual load. Beyond that, every additional concept that has to be held in mind in order to understand an object contributes to mental overload.
Implicit concepts, recognized or unrecognized, count just as much as explicit references. Although we can generally ignore dependencies on primitive values such as integers and strings, we can’t ignore what they represent. For example, in the first paint mixing examples, the Paint object held three public integers representing red, yellow, and blue color values. The creation of the Pigment Color object did not increase the number of concepts involved or the dependencies. It did make the ones that were already there more explicit and easier to understand. On the other hand, the Collection size() operation returns an int that is simply a count, the basic meaning of an integer, so no new concept is implied.
Every dependency is suspect until proven basic to the concept behind the object. This scrutiny starts with the factoring of the model concepts themselves. Then it requires attention to each individual association and operation. Model and design choices can chip away at dependencies—often to zero.
Low coupling is fundamental to object design. When you can, go all the way. Eliminate all other concepts from the picture. Then the class will be completely self-contained and can be studied and understood alone. Every such self-contained class significantly eases the burden of understanding a MODULE.
Dependencies on other classes within the same module are less harmful than those outside. Likewise, when two objects are naturally tightly coupled, multiple operations involving the same pair can actually clarify the nature of the relationship. The goal is not to eliminate all dependencies, but to eliminate all nonessential ones. If every dependency can’t be eliminated, each one that is removed frees the developer to concentrate on the remaining conceptual dependencies.
Try to factor the most intricate computations into STANDALONE CLASSES, perhaps by modeling VALUE OBJECTS held by the more connected classes.
The concept of paint is fundamentally related to the concept of color. But color, even of pigment, can be considered without paint. By making these two concepts explicit and distilling the relationship, the remaining one-way association says something important, and the Pigment Color class, where most of the computational complexity lies, can be studied and tested alone.
Image Image Image Low coupling is a basic way to reduce conceptual overload. A STANDALONE CLASS is an extreme of low coupling.
Eliminating dependencies should not mean dumbing down the model by arbitrarily reducing everything to primitives. The final pattern of this chapter, CLOSURE OF OPERATIONS, is an example of a technique for reducing dependency while keeping a rich interface. . . .
CLOSURE OF OPERATIONS If we take two real numbers and multiply them together, we get another real number. [The real numbers are all the rational numbers and all the irrational numbers.] Because this is always true, we say that the real numbers are “closed under the operation of multiplication”: there is no way to escape the set. When you combine any two elements of the set, the result is also included in the set.
—The Math Forum, Drexel University
Of course, there will be dependencies, and that isn’t a bad thing when the dependency is fundamental to the concept. Stripping interfaces down to deal with nothing but primitives can impoverish them. But a lot of unnecessary dependencies, and even entire concepts, get introduced at interfaces.
Most interesting objects end up doing things that can’t be characterized by primitives alone.
Another common practice in refined designs is what I call “CLOSURE OF OPERATIONS.” The name comes from that most refined of conceptual systems, mathematics. 1 + 1 = 2. The addition operation is closed under the set of real numbers. Mathematicians are fanatical about not introducing extraneous concepts, and the property of closure provides them a way of defining an operation without involving any other concepts. We are so accustomed to the refinement of mathematics that it can be hard to grasp how powerful its little tricks are. But this one is used extensively in software designs as well. The basic use of XSLT is to transform one XML document into another XML document. This sort of XSLT operation is closed under the set of XML documents. The property of closure tremendously simplifies the interpretation of an operation, and it is easy to think about chaining together or combining closed operations.
Therefore:
Where it fits, define an operation whose return type is the same as the type of its argument(s). If the implementer has state that is used in the computation, then the implementer is effectively an argument of the operation, so the argument(s) and return value should be of the same type as the implementer. Such an operation is closed under the set of instances of that type. A closed operation provides a high-level interface without introducing any dependency on other concepts.
This pattern is most often applied to the operations of a VALUE OBJECT. Because the life cycle of an ENTITY has significance in the domain, you can’t just conjure up a new one to answer a question. There are operations that are closed under an ENTITY type. You could ask an Employee object for its supervisor and get back another Employee. But in general, ENTITIES are not the sort of concepts that are likely to be the result of a computation. So, for the most part, this is an opportunity to look for in the VALUE OBJECTS.
An operation can be closed under an abstract type, in which case specific arguments can be of different concrete classes. After all, addition is closed under real numbers, which can be either rational or irrational.
As you’re experimenting, looking for ways to reduce interdependence and increase cohesion, you sometimes get halfway to this pattern. The argument matches the implementer, but the return type is different, or the return type matches the receiver and the argument is different. These operations are not closed, but they do give some of the advantages of CLOSURE. When the extra type is a primitive or basic library class, it frees the mind almost as much as CLOSURE.
In the earlier example, the Pigment Color mixedWith() operation was closed under Pigment Colors, and there are several other examples scattered through the book. Here’s an example that shows how useful this idea can be, even when true CLOSURE isn’t reached.
Example: Selecting from Collections In Java, if you want to select a subset of elements from a Collection, you request an Iterator. Then you iterate through the elements, testing each one, probably accumulating the matches into a new Collection.
Set employees = (some Set of Employee objects); Set lowPaidEmployees = new HashSet(); Iterator it = employees.iterator(); while (it.hasNext()) { Employee anEmployee = it.next(); if (anEmployee.salary() < 40000) lowPaidEmployees.add(anEmployee); }
Conceptually, I’ve selected a subset of a set. What do I need with this extra concept, Iterator, and all its mechanical complexity? In Smalltalk, I would call the “select” operation on the Collection, passing in the test as an argument. The return would be a new Collection containing just the elements that passed the test.
employees := (some Set of Employee objects). lowPaidEmployees := employees select: [:anEmployee | anEmployee salary < 40000].
The Smalltalk Collections provide other such FUNCTIONS that return derived Collections, which can be of several concrete classes. The operations are not closed, because they take a “block” as an argument. But blocks are a basic library type in Smalltalk, so they don’t add to the developer’s mental load. Because the return value matches the implementer, they can be strung together, like a series of filters. They are easy to write and easy to read. They do not introduce extraneous concepts that are irrelevant to the problem of selecting subsets.
Image Image Image The patterns presented in this chapter illustrate a general style of design and a way of thinking about design. Making software obvious, predictable, and communicative makes abstraction and encapsulation effective. Models can be factored so that objects are simple to use and understand yet still have rich, high-level interfaces.
These techniques require fairly advanced design skills to apply and sometimes even to write a client. The usefulness of a MODEL-DRIVEN DESIGN is sensitive to the quality of the detailed design and implementation decisions, and it only takes a few confused developers to derail a project from the goal.
That said, for the team willing to cultivate its modeling and design skills, these patterns and the way of thinking they reflect yield software that developers can work and rework to create complex software.
DECLARATIVE DESIGN ASSERTIONS can lead to much better designs, even with our relatively informal way of testing them. But there can be no real guarantees in handwritten software. To name just one way of evading ASSERTIONS, code could have additional side effects that were not specifically excluded. No matter how MODEL-DRIVEN our design is, we still end up writing procedures to produce the effect of the conceptual interactions. And we spend so much of our time writing boilerplate code that doesn’t really add any meaning or behavior. This is tedious and fraught with error, and the bulk of it obscures the meaning of our model. (Some languages are better than others, but all require us to do a lot of grunt work.) INTENTION-REVEALING INTERFACES and the other patterns in this chapter help, but they can never give conventional object-oriented programs formal rigor.
These are some of the motivations behind declarative design. This term means many things to many people, but usually it indicates a way to write a program, or some part of a program, as a kind of executable specification. A very precise description of properties actually controls the software. In its various forms, this could be done through a reflection mechanism or at compile time through code generation (producing conventional code automatically, based on the declaration). This approach allows another developer to take the declaration at face value. It is an absolute guarantee.
Generating a running program from a declaration of model properties is a kind of Holy Grail of MODEL-DRIVEN DESIGN, but it does have its pitfalls in practice. For example, here are just two particular problems I’ve encountered more than once.
• A declaration language not expressive enough to do everything needed, but a framework that makes it very difficult to extend the software beyond the automated portion
• Code-generation techniques that cripple the iterative cycle by merging generated code into handwritten code in a way that makes regeneration very destructive
The unintended consequence of many attempts at declarative design is the dumbing-down of the model and application, as developers, trapped by the limitations of the framework, enact design triage in order to get something delivered.
Rule-based programming with an inference engine and a rule base is another promising approach to declarative design. Unfortunately, subtle issues can undermine this intention.
Although a rules-based program is declarative in principle, most systems have “control predicates” that were added to allow performance tuning. This control code introduces side effects, so that the behavior is no longer dictated completely by the declared rules. Adding, removing, or reordering the rules can cause unexpected, incorrect results. Therefore, a logic programmer has to be careful to keep the effect of code obvious, just as an object programmer does.
Many declarative approaches can be corrupted if the developers bypass them intentionally or unintentionally. This is likely when the system is difficult to use or overly restrictive. Everyone has to follow the rules of the framework in order to get the benefits of a declarative program.
The greatest value I’ve seen delivered has been when a narrowly scoped framework automates a particularly tedious and error-prone aspect of the design, such as persistence and object-relational mapping. The best of these unburden developers of drudge work while leaving them complete freedom to design.
Domain-Specific Languages An interesting approach that is sometimes declarative is the domain-specific language. In this style, client code is written in a programming language tailored to a particular model of a particular domain. For example, a language for shipping systems might include terms such as cargo and route, along with syntax for associating them. The program is then compiled, often into a conventional object-oriented language, where a library of classes provides implementations for the terms in the language.
In such a language, programs can be extremely expressive, and make the strongest connection with the UBIQUITOUS LANGUAGE. This is an exciting concept, but domain-specific languages also have their drawbacks in the approaches I’ve seen based on object-oriented technology.
To refine the model, a developer needs to be able to modify the language. This may involve modifying grammar declarations and other language-interpreting features, as well as modifying underlying class libraries. I’m all in favor of learning advanced technology and design concepts, but we have to soberly assess the skills of a particular team, as well as the likely skills of future maintenance teams. Also, there is value in the seamlessness of an application and a model implemented in the same language. Another drawback is that it can be difficult to refactor client code to conform to a revised model and its associated domain-specific language. Of course, someone may come up with a technical fix for the refactoring problems.
From the Ground Up
A different paradigm might handle domain-specific languages better than objects. In the Scheme programming language, a representative of the “functional programming” family, something very similar is part of standard programming style, so that the expressiveness of a domain-specific language can be created without bifurcating the system.
This technique might be most useful for very mature models, perhaps where client code is being written by a different team. Generally, such setups lead to the poisonous distinction between highly technical framework builders and technically unskilled application builders, but it doesn’t have to be that way.
In the scheme programming language, something very similar is part of standard programming style, so that the expressiveness of a domain-specific language can be created without bifurcating the system.
A DECLARATIVE STYLE OF DESIGN Once your design has INTENTION-REVEALING INTERFACES, SIDE-EFFECT-FREE FUNCTIONS, and ASSERTIONS, you are edging into declarative territory. Many of the benefits of declarative design are obtained once you have combinable elements that communicate their meaning, and have characterized or obvious effects, or no observable effects at all.
A supple design can make it possible for the client code to use a declarative style of design. To illustrate, the next section will bring together some of the patterns in this chapter to make the SPECIFICATION more supple and declarative.
Extending SPECIFICATIONS in a Declarative Style Chapter 9 covered the basic concept of SPECIFICATION, the roles it can play in a program, and some sense of what is involved in implementation. Now let’s take a look at a few bells and whistles that can be very useful in some situations with complicated rules.
SPECIFICATION is an adaptation of an established formalism, the predicate. Predicates have other useful properties that we can draw on, selectively.
Combining SPECIFICATIONS Using Logical Operators When using SPECIFICATIONS, you quickly come across situations in which you would like to combine them. As just mentioned, a SPECIFICATION is an example of a predicate, and predicates can be combined and modified with the operations “AND,” “OR,” and “NOT.” These logical operations are closed under predicates, so SPECIFICATION combinations will exhibit CLOSURE OF OPERATIONS.
As significant generalized capability is built into SPECIFICATIONS, it becomes very useful to create an abstract class or interface that can be used for SPECIFICATIONS of all sorts. This means typing arguments as some high-level abstract class.
public interface Specification { boolean isSatisfiedBy(Object candidate); }
This abstraction calls for a guard clause at the beginning of the method, but otherwise it does not affect functionality. For example, the Container Specification (from the example in Chapter 9, on page 236) would be modified this way:
public class ContainerSpecification implements Specification { private ContainerFeature requiredFeature;
public ContainerSpecification(ContainerFeature required) { requiredFeature = required; }
boolean isSatisfiedBy(Object candidate){ if (!candidate instanceof Container) return false;
return
(Container)candidate.getFeatures().contains(requiredFeature); } }
Now, let’s extend the Specification interface by adding the three new operations:
public interface Specification { boolean isSatisfiedBy(Object candidate);
Specification and(Specification other); Specification or(Specification other); Specification not(); }
Recall that some Container Specifications were configured to require ventilated Containers and others to require armored Containers. A chemical that is both volatile and explosive would, presumably, need both of these SPECIFICATIONS. Easily done, using the new methods.
Specification ventilated = new ContainerSpecification(VENTILATED); Specification armored = new ContainerSpecification(ARMORED);
Specification both = ventilated.and(armored);
The declaration defines a new Specification object with the expected properties. This combination would have required a more complicated Container Specification, and would still have been special purpose.
Suppose we had more than one kind of ventilated Container. It might not matter for some items which kind they were packed into. They could be placed in either type.
Specification ventilatedType1 = new ContainerSpecification(VENTILATED_TYPE_1); Specification ventilatedType2 = new ContainerSpecification(VENTILATED_TYPE_2);
Specification either = ventilatedType1.or(ventilatedType2);
If it was considered wasteful to store sand in specialized containers, we could prohibit it by SPECIFYING a “cheap” container with no special features.
Specification cheap = (ventilated.not()).and(armored.not());
This constraint would have prevented some of the suboptimal behavior of the prototype warehouse packer discussed in Chapter 9.
The ability to build complex specifications out of simple elements increases the expressiveness of the code. The combinations are written in a declarative style.
Depending on how SPECIFICATIONS are implemented, these operators may be easy or difficult to provide. What follows is a very simple implementation, which would be inefficient in some situations and quite practical in others. It is meant as an explanatory example. Like any pattern, there are many ways to implement it.
public abstract class AbstractSpecification implements Specification { public Specification and(Specification other) { return new AndSpecification(this, other); } public Specification or(Specification other) { return new OrSpecification(this, other); } public Specification not() { return new NotSpecification(this); } }
public class AndSpecification extends AbstractSpecification { Specification one; Specification other; public AndSpecification(Specification x, Specification y) { one = x; other = y; } public boolean isSatisfiedBy(Object candidate) { return one.isSatisfiedBy(candidate) && other.isSatisfiedBy(candidate); } }
public class OrSpecification extends AbstractSpecification { Specification one; Specification other; public OrSpecification(Specification x, Specification y) { one = x; other = y; } public boolean isSatisfiedBy(Object candidate) { return one.isSatisfiedBy(candidate) || other.isSatisfiedBy(candidate); } }
public class NotSpecification extends AbstractSpecification { Specification wrapped;
public NotSpecification(Specification x) { wrapped = x; } public boolean isSatisfiedBy(Object candidate) { return !wrapped.isSatisfiedBy(candidate); } }
Image Figure 10.14. COMPOSITE design of SPECIFICATION
This code was written to be as easy as possible to read in a book. As I said, there may be situations in which this is inefficient. However, other implementation options are possible that would minimize object count or boost speed, or perhaps be compatible with idiosyncratic technologies present in some project. The important thing is a model that captures the key concepts of the domain, along with an implementation that is faithful to that model. That leaves a lot of room to solve performance problems.
Also, this full generality is not needed in many cases. In particular, AND tends to be used a lot more than the others, and it also tends to create less implementation complexity. Don’t be afraid to implement only AND, if that is all you need.
Way back in Chapter 2, in the example dialog on page 30, the developers had apparently not implemented the “satisfied by” behavior of their SPECIFICATION. Up to that point, the SPECIFICATION had been used only for building to order. Even so, the abstraction was intact, and adding functionality was relatively easy. Using a pattern doesn’t mean building features you don’t need. They can be added later, as long as the concepts don’t get muddled.
Example: One Alternative Implementation of COMPOSITE SPECIFICATION Some implementation environments don’t accommodate very fine grained objects very well. I once worked on a project with an object database that insisted on giving an object ID to every object and then tracking it. Each object had lots of overhead in memory space and performance, and total address space was a limiting factor. I employed SPECIFICATIONS at some important points in the domain design, which I think was a good decision. But I used a slightly more elaborate version of the implementation described in this chapter, which was definitely a mistake. It resulted in millions of very fine grained objects that contributed to bogging the system down.
Here is an example of an alternative implementation that encodes the composite SPECIFICATION as a string or array encoding the logical expression, to be interpreted at runtime.
(Don’t worry if you do not see how you would implement this. The important thing is to realize that there are many ways of implementing a SPECIFICATION with logical operators, and so if the simple one is not practical in your situation, you have options.)
Image When you want to test a candidate, you have to interpret this structure, which can be done by popping off each element, then evaluating it or popping off the next as required by an operator. You would end up with this:
and(not(armored), not(ventilated))
This design has pros (+) and cons (–):
Low object count
Efficient use of memory
– Requires more sophisticated developers
You have to find an implementation with trade-offs that work for your circumstances. The same pattern and model can underlie very different implementations.
Subsumption This final feature is not usually needed and can be difficult to implement, but every now and then it solves a really hard problem. It also elucidates the meaning of a SPECIFICATION.
Consider again the chemical warehouse packer from the example on page 235. Recall that each Chemical had a Container Specification, and the Packer SERVICE guaranteed that all these would be satisfied when Drums are assigned to Containers. All is well... until someone changes the regulations.
Every few months a new set of rules is issued, and our users would like to be able to produce a list of the chemical types that now have more stringent requirements.
Of course, we could give a partial answer (and one the users probably also want) by running a validation of each Drum in the inventory, with the new SPECIFICATIONS in place, and finding all those that no longer meet the SPEC. This would tell the users which Drums in the existing inventory they need to move.
But what they asked for was a list of chemicals whose handling has become more stringent. Perhaps there are none in-house right now, or perhaps they just happened to be packed into a more stringent container. In either case, the report just described would not list them.
Let’s introduce a new operation for directly comparing two SPECIFICATIONS.
boolean subsumes(Specification other);
A more stringent SPEC subsumes a less stringent one. It could take its place without any previous requirement being neglected.
Image Figure 10.15. The SPECIFICATION for a gasoline container has been tightened.
In the language of SPECIFICATION, we would say that the new SPECIFICATION subsumes the old SPECIFICATION, because any candidate that would satisfy the new SPEC would also satisfy the old.
If each of these SPECIFICATIONS is viewed as a predicate, subsumption is equivalent to logical implication. Using conventional notation, A → B means that statement A implies statement B, so that if A is true, B is also true.
Let’s apply this logic to our container-matching needs. When a SPECIFICATION is being changed, we would like to know if the proposed new SPEC meets all the conditions of the old one.
New Spec → Old Spec
That is, if the new spec is true, then the old spec is also true. Proving a logical implication in a general way is very difficult, but special cases can be easy. For example, particular parameterized SPECS can define their own subsumption rule.
public class MinimumAgeSpecification { int threshold;
public boolean isSatisfiedBy(Person candidate) { return candidate.getAge() >= threshold; }
public boolean subsumes(MinimumAgeSpecification other) { return threshold >= other.getThreshold(); } }
A JUnit test might contain this:
drivingAge = new MinimumAgeSpecification(16); votingAge = new MinimumAgeSpecification(18); assertTrue(votingAge.subsumes(drivingAge));
Another practical special case, one suited to address the Container Specification problem, is a SPECIFICATION interface combining subsumption with the single logical operator AND.
public interface Specification { boolean isSatisfiedBy(Object candidate); Specification and(Specification other); boolean subsumes(Specification other); }
Proving implication with only the AND operator is simple:
A AND B → A
or, in a more complicated case:
A AND B AND C → A AND B
So if the Composite Specification is able to collect all the leaf SPECIFICATIONS that are “ANDed” together, then all we have to do is check that the subsuming SPECIFICATION has all the leaves that the subsumed one has, and maybe some extra ones as well—its leaves are a superset of the other SPEC’s set of leaves.
public boolean subsumes(Specification other) { if (other instanceof CompositeSpecification) { Collection otherLeaves = (CompositeSpecification) other.leafSpecifications(); Iterator it = otherLeaves.iterator(); while (it.hasNext()) { if (!leafSpecifications().contains(it.next())) return false; } } else { if (!leafSpecifications().contains(other)) return false; } return true; }
This interaction could be enhanced to compare carefully chosen parameterized leaf SPECIFICATIONS and some other complications. Unfortunately, when OR and NOT are included, these proofs become much more involved. In most situations it is best to avoid such complexity by making a choice, either forgoing some of the operators or forgoing subsumption. If both are needed, consider carefully if the benefit is great enough to justify the difficulty.
Image ANGLES OF ATTACK This chapter has presented a raft of techniques to clarify the intent of code, to make the consequences of using it transparent, and to decouple model elements. Even so, this kind of design is difficult. You can’t just look at an enormous system and say, “Let’s make this supple.” You have to choose targets. Here are a couple of broad approaches, followed by an extended example showing how the patterns are fit together and used to take on a bigger design.
Carve Off Subdomains You just can’t tackle the whole design at once. Pick away at it. Some aspects of the system will suggest approaches to you, and they can be factored out and worked over. You may see a part of the model that can be viewed as specialized math; separate that. Your application enforces complex rules restricting state changes; pull this out into a separate model or simple framework that lets you declare the rules. With each such step, not only is the new module clean, but also the part left behind is smaller and clearer. Part of what’s left is written in a declarative style, a declaration in terms of the special math or validation framework, or whatever form the subdomain takes.
It is more useful to make a big impact on one area, making a part of the design really supple, than to spread your efforts thin. Chapter 15 discusses in more depth how to choose and manage subdomains.
Draw on Established Formalisms, When You Can Creating a tight conceptual framework from scratch is something you can’t do every day. Sometimes you discover and refine one of these over the course of the life of a project. But you can often use and adapt conceptual systems that are long established in your domain or others, some of which have been refined and distilled over centuries. Many business applications involve accounting, for example. Accounting defines a well-developed set of ENTITIES and rules that make for an easy adaptation to a deep model and a supple design.
There are many such formalized conceptual frameworks, but my personal favorite is math. It is surprising how useful it can be to pull out some twist on basic arithmetic. Many domains include math somewhere. Look for it. Dig it out. Specialized math is clean, combinable by clear rules, and people find it easy to understand. One example from my past is “Shares Math,” which will end this chapter.
Example: Integrating the Patterns: Shares Math Chapter 8 told the story of a model breakthrough on a project to build a syndicated loan system. Now this example will go into detail, focusing on just one feature of a design comparable to the one on that project.
One requirement of that application was that when the borrower makes a principal payment, the money is, by default, prorated according to the lenders’ shares in the loan.
Initial Design for Payment Distribution As we refactor it, this code will get easier to understand, so don’t get stuck on this version.
Image Figure 10.16
public class Loan { private Map shares;
//Accessors, constructors, and very simple methods are excluded
public Map distributePrincipalPayment(double paymentAmount) { Map paymentShares = new HashMap(); Map loanShares = getShares(); double total = getAmount(); Iterator it = loanShares.keySet().iterator(); while(it.hasNext()) { Object owner = it.next(); double initialLoanShareAmount = getShareAmount(owner); double paymentShareAmount = initialLoanShareAmount / total * paymentAmount; Share paymentShare = new Share(owner, paymentShareAmount); paymentShares.put(owner, paymentShare);
double newLoanShareAmount =
initialLoanShareAmount - paymentShareAmount;
Share newLoanShare =
new Share(owner, newLoanShareAmount);
loanShares.put(owner, newLoanShare);
}
return paymentShares;
}
public double getAmount() { Map loanShares = getShares(); double total = 0.0; Iterator it = loanShares.keySet().iterator(); while(it.hasNext()) { Share loanShare = (Share) loanShares.get(it.next()); total = total + loanShare.getAmount(); } return total; } }
Separating Commands and SIDE-EFFECT-FREE FUNCTIONS This design already has INTENTION-REVEALING INTERFACES. But the distributePaymentPrincipal() method does a dangerous thing: It calculates the shares for distribution and also modifies the Loan. Let’s refactor to separate the query from the modifier.
Image Figure 10.17
public void applyPrincipalPaymentShares(Map paymentShares) { Map loanShares = getShares(); Iterator it = paymentShares.keySet().iterator(); while(it.hasNext()) { Object lender = it.next(); Share paymentShare = (Share) paymentShares.get(lender); Share loanShare = (Share) loanShares.get(lender); double newLoanShareAmount = loanShare.getAmount() - paymentShare.getAmount(); Share newLoanShare = new Share(lender, newLoanShareAmount); loanShares.put(lender, newLoanShare); } }
public Map calculatePrincipalPaymentShares(double paymentAmount) { Map paymentShares = new HashMap(); Map loanShares = getShares(); double total = getAmount(); Iterator it = loanShares.keySet().iterator(); while(it.hasNext()) { Object lender = it.next(); Share loanShare = (Share) loanShares.get(lender); double paymentShareAmount = loanShare.getAmount() / total * paymentAmount; Share paymentShare = new Share(lender, paymentShareAmount); paymentShares.put(lender, paymentShare); } return paymentShares; }
Client code now looks like this:
Map distribution = aLoan.calculatePrincipalPaymentShares(paymentAmount); aLoan.applyPrincipalPaymentShares(distribution);
Not too bad. The FUNCTIONS have encapsulated a lot of complexity behind INTENTION-REVEALING INTERFACES. But the code does begin to multiply some when we add applyDrawdown(), calculateFeePaymentShares(), and so on. Each extension complicates the code and weighs it down. This might be a point where the granularity is too coarse. The conventional approach would be to break the calculation methods down into subroutines. That could well be a good step along the way, but we ultimately want to see the underlying conceptual boundaries and deepen the model. The elements of a design with such a CONCEPT-CONTOURING grain could be combined to produce the needed variations.
Making an Implicit Concept Explicit There are enough pointers now to start probing for that new model. The Share objects are passive in this implementation, and they are being manipulated in complex, low-level ways. This is because most of the rules and calculations about shares don’t apply to single shares, but to groups of them. There is a missing concept: shares are related to each other as parts making up a whole. Making this concept explicit will let us express those rules and calculations more succinctly.
Image Figure 10.18
The Share Pie represents the total distribution of a specific Loan. It is an ENTITY whose identity is local within the AGGREGATE of the Loan. The actual distribution calculations can be delegated to the Share Pie.
Image Figure 10.19
public class Loan { private SharePie shares;
//Accessors, constructors, and straightforward methods //are omitted
public Map calculatePrincipalPaymentDistribution( double paymentAmount) { return getShares().prorated(paymentAmount); } public void applyPrincipalPayment(Map paymentShares) { shares.decrease(paymentShares); } }
The Loan is simplified, and the Share calculations are centralized in a VALUE OBJECT focused on that responsibility. Still, the calculations haven’t really become more versatile or easier to use.
Share Pie Becomes a VALUE OBJECT: Cascade of Insights Often, the hands-on experience of implementing a new design will trigger a new insight into the model itself. In this case, the tight coupling of the Loan and Share Pie seems to be obscuring the relationship of the Share Pie and the Shares. What would happen if we made Share Pie a VALUE OBJECT?
This would mean that increase(Map) and decrease(Map) would not be allowed, because the Share Pie would have to be immutable. To change the Share Pie’s value, the whole Pie would have to be replaced. So you could have operations such as addShares(Map) that would return a whole new, larger Share Pie.
Let’s go all the way to CLOSURE OF OPERATIONS. Instead of “increasing” a Share Pie or adding Shares to it, just add two Share Pies together: the result is the new, larger Share Pie.
We can partially close the prorate() operation over Share Pie just by changing the return type. Renaming it to prorated() emphasizes its lack of side effects. “Shares Math” starts to take shape, initially with four operations.
Image Figure 10.20
We can make some well-defined ASSERTIONS about our new VALUE OBJECTS, the Share Pies. Each method means something.
public class SharePie { private Map shares = new HashMap();
//Accessors and other straightforward methods are omitted
public double getAmount() { double total = 0.0; Iterator it = shares.keySet().iterator(); while(it.hasNext()) { The whole is equal to the Share loanShare = getShare(it.next()); sum of its parts. total = total + loanShare.getAmount(); } return total; }
public SharePie minus(SharePie otherShares) { SharePie result = new SharePie(); Set owners = new HashSet(); owners.addAll(getOwners()); owners.addAll(otherShares.getOwners()); The difference between Iterator it = owners.iterator(); two Pies is the difference while(it.hasNext()) { between each owner's Object owner = it.next(); share. double resultShareAmount = getShareAmount(owner) – otherShares.getShareAmount(owner); result.add(owner, resultShareAmount); } return result; }
public SharePie plus(SharePie otherShares) { The combination of two //Similar to implementation of minus() Pies is the combination of } each owner's share.
public SharePie prorated(double amountToProrate) { SharePie proration = new SharePie(); double basis = getAmount(); An amount can be divided Iterator it = shares.keySet().iterator(); proportionately while(it.hasNext()) { among all shareholders. Object owner = it.next(); Share share = getShare(owner); double proratedShareAmount = share.getAmount() / basis * amountToProrate; proration.add(owner, proratedShareAmount); } return proration; }
}
The Suppleness of the New Design At this point, the methods in the all-important Loan class could be as simple as this:
public class Loan { private SharePie shares;
//Accessors, constructors, and straightforward methods //are omitted
public SharePie calculatePrincipalPaymentDistribution( double paymentAmount) { return shares.prorated(paymentAmount); }
public void applyPrincipalPayment(SharePie paymentShares) { setShares(shares.minus(paymentShares)); }
Each of these short methods states its meaning. Applying a principal payment means that you subtract the payment from the loan, share by share. Distributing a principal payment is done by dividing the amount pro rata among the shareholders. The design of the Share Pie has allowed us to use a declarative style in the Loan code, producing code that begins to read like a conceptual definition of the business transaction, rather than a calculation.
Other transaction types (too complicated to list before) can be declared easily now. For example, loan drawdowns are divided among lenders based on their shares of the Facility. The new draw-down is added to the outstanding Loan. In our new domain language:
public class Facility { private SharePie shares; . . . public SharePie calculateDrawdownDefaultDistribution( double drawdownAmount) { return shares.prorated(drawdownAmount); } }
public class Loan { . . . public void applyDrawdown(SharePie drawdownShares) { setShares(shares.plus(drawdownShares)); } }
To see the deviation of each lender from its agreed contribution, take the theoretical distribution of the outstanding Loan amount and subtract it from the Loan’s actual shares:
SharePie originalAgreement = aFacility.getShares().prorated(aLoan.getAmount()); SharePie actual = aLoan.getShares(); SharePie deviation = actual.minus(originalAgreement);
Certain characteristics of the Share Pie design make for this easy recombination and communication in the code.
• Complex logic is encapsulated in specialized VALUE OBJECTS with SIDE-EFFECT-FREE FUNCTIONS. Most complex logic has been encapsulated in these immutable objects. Because Share Pies are VALUE OBJECTS, the math operations can create new instances, which we can use freely to replace outdated instances.
None of the Share Pie methods causes any change to any existing object. This allows us to use plus(), minus(), and prorated() freely in intermediate calculations, combining them, expecting them to do what their names suggest, and nothing more. It also allows us to build analytical features based on the same methods. (Before, they could be called only when an actual distribution was made, because the data would change after each call.)
• State-modifying operations are simple and characterized with ASSERTIONS. The high-level abstractions of Shares Math allow invariants of transactions to be written concisely in a declarative style. For example, the deviation is the actual pie minus the Loan amount prorated based on the Facility’s Share Pie.
• Model concepts are decoupled; operations entangle a minimum of other types. Some methods on Share Pie exhibit CLOSURE OF OPERATIONS (the methods to add or subtract are closed under Share Pies). Others take simple amounts as arguments or return values; they are not closed, but they add little to the conceptual load. The Share Pie interacts closely with only one other class, Share. As a result, the Share Pie is self-contained, easily understood, easily tested, and easily combined to form declarative transactions. These properties were inherited from the math formalism.
• Familiar formalism makes the protocol easy to grasp. A wholly original protocol for manipulating shares could have been devised based on financial terminology. In principle, it could have been made supple. But it would have had two disadvantages. First, it would have to be invented, a difficult and uncertain task. Second, it would have to be learned by each person who dealt with it. People who see Shares Math recognize a system they already know, and because the design has been kept carefully consistent with the rules of arithmetic, those people are not misled.
Pulling out the part of the problem that corresponded to the formalism of math, we arrived at a supple design for Shares that further distills the core Loan and Facility methods. (See Chapter 15 for discussion of the CORE DOMAIN.)
Supple design has a profound effect on the ability of software to cope with change and complexity. As the examples in this chapter have shown, it often hinges on quite detailed modeling and design decisions. The impact can go beyond a specific modeling and design problem. Chapter 15 will discuss the strategic value of supple design as one of several tools for distilling a domain model to make large and complex projects more tractable.