-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture Guidelines
We want to be careful with the distinction between “encapsulation” and “information hiding.” The terms are often used interchangeably but actually refer to two separate, and largely orthogonal, qualities:
Ensures that the behavior of an object can only be affected through its API. It lets us control how much a change to one object will impact other parts of the system by ensuring that there are no unexpected dependencies between unrelated components.
Conceals how an object implements its functionality behind the abstraction of its API. It lets us work with higher abstractions by ignoring lower-level details that are unrelated to the task at hand. We’re most aware of encapsulation when we haven’t got it.When working with badly encapsulated code, we spend too much time tracing what the potential effects of a change might be, looking at where objects are created, what common data they hold, and where their contents are referenced.The topic has inspired two books that we know of, [Feathers04] and [Demeyer03].
Many object-oriented languages support encapsulation by providing control over the visibility of an object’s features to other objects, but that’s not enough. Objects can break encapsulation by sharing references to mutable objects, an effect known asaliasing. Aliasing is essential for conventional object- oriented systems (other- wise no two objects would be able to communicate), but accidental aliasing can couple unrelated parts of a system so it behaves mysteriously and is inflexible to change. We follow standard practices to maintain encapsulation when coding: define immutable value types, avoid global variables and singletons, copy collections and mutable values when passing them between objects, and so on.
Every object should have a single, clearly defined responsibility; this is the “single responsibility” principle [Martin02]. When we’re adding behavior to a system, this principle helps us decide whether to extend an existing object or create a new service for an object to call.Our heuristic is that we should be able to describe what an object does without using any conjunctions (“and,” “or”). If we find ourselves adding clauses to the description, then the object probably should be broken up into collaborating objects, usually one for each clause. This principle also applies when we’re combining objects into new abstractions. If we’re packaging up behavior implemented across several objects into a single construct, we should be able to describe its responsibility clearly; there are some related ideas below in the “Composite Simpler Than the Sum of Its Parts” and “Context Independence” sections.
As we organize our system, we must decide what is inside and outside each object, so that the object provides a coherent abstraction with a clear API. Much of the point of an object, as we discussed above, is to encapsulate access to its internals through its API and to hide these details from the rest of the system. An object communicates with other objects in the system by sending and receiving messages, as in Figure 6.2; the objects it communicates with directly are its peers.
If we expose too much of an object’s internals through its API, its clients will end up doing some of its work. We’ll have distributed behavior across too many objects (they’ll be coupled to- gether), increasing the cost of maintenance because any changes will now ripple across the code.
We have objects with single responsibilities, communicating with their peers through messages in clean APIs, but what do they say to each other? We categorize an object’s peers (loosely) into three types of relationship. An object might have:
Services that the object requires from its peers so it can perform its responsi- bilities. The object cannot function without these services. It should not be possible to create the object without them. For example, a graphics package will need something like a screen or canvas to draw on—it doesn’t make sense without one.
Peers that need to be kept up to date with the object’s activity. The object will notify interested peers whenever it changes state or performs a significant action. Notifications are “fire and forget”; the object neither knows nor cares which peers are listening. Notifications are so useful because they decouple objects from each other. For example, in a user interface system, a button component promises to notify any registered listeners when it’s clicked, but does not know what those listeners will do. Similarly, the listeners expect to be called but know nothing of the way the user interface dispatches its events.
Peers that adjust the object’s behavior to the wider needs of the system. This includes policy objects that make decisions on the object’s behalf (the Strat- egy pattern in [Gamma94]) and component parts of the object if it’s a com- posite. For example, a Swing JTable will ask a TableCellRenderer to draw a cell’s value, perhaps as RGB (Red, Green, Blue) values for a color. If we change the renderer, the table will change its presentation, now displaying the HSB (Hue, Saturation, Brightness) values.
These stereotypes are only heuristics to help us think about the design, not hard rules, so we don’t obsess about finding just the right classification of an object’s peers. What matters most is the context in which the collaborating objects are used. For example, in one application an auditing log could be a dependency, because auditing is a legal requirement for the business and no object should be created without an audit trail. Elsewhere, it could be a notification, because auditing is a user choice and objects will function perfectly well without it. Another way to look at it is that notifications are one-way: A notification lis- tener may not return a value, call back the caller, or throw an exception, since there may be other listeners further down the chain. A dependency or adjustment, on the other hand, may do any of these, since there’s a direct relationship.
We try to make sure that we always create a valid object. For dependencies, this means that we pass them in through the constructor. They’re required, so there’s no point in creating an instance of an object until its dependencies are available, and using the constructor enforces this constraint in the object’s definition. Partially creating an object and then finishing it off by setting properties is brittle because the programmer has to remember to set all the dependencies.When the object changes to add new dependencies, the existing client code will still compile even though it no longer constructs a valid instance. At best this will cause a NullPointerException, at worst it will fail misleadingly. Notifications and adjustments can be passed to the constructor as a convenience. Alternatively, they can be initialized to safe defaults and overwritten later (note that there is no safe default for a dependency). Adjustments can be initialized to common values, and notifications to a null object [Woolf98] or an empty collection. We then add methods to allow callers to change these default values, and add or remove listeners.
The API of a composite object should not be more complicated than that of any of its components.
In software, a user interface component for editing money values might have two subcomponents: one for the amount and one for the currency. For the component to be useful, its API should manage both values together, otherwise the client code could just control it subcomponents directly.
moneyEditor.getAmountField().setText(String.valueOf(money.amount());
moneyEditor.getCurrencyField().setText(money.currencyCode());
The “Tell, Don’t Ask” convention can start to hide an object’s structure from its clients but is not a strong enough rule by itself. For example, we could replace the getters in the first version with setters:
moneyEditor.setAmountField(money.amount());
moneyEditor.setCurrencyField(money.currencyCode());
This still exposes the internal structure of the component, which its client still has to manage explicitly. We can make the API much simpler by hiding within the component everything about the way money values are displayed and edited, which in turn simplifies the client code:
moneyEditor.setValue(money);
While the “composite simpler than the sum of its parts” rule helps us decide whether an object hides enough information, the “context independence” rule helps us decide whether an object hides too much or hides the wrong information. A system is easier to change if its objects are context-independent; that is, if each object has no built-in knowledge about the system in which it executes. This allows us to take units of behavior (objects) and apply them in new situations. To be context-independent, whatever an object needs to know about the larger environment it’s running in must be passed in. Those relationships might be “permanent” (passed in on construction) or “transient” (passed in to the method that needs them). In this “paternalistic” approach, each object is told just enough to do its job and wrapped up in an abstraction that matches its vocabulary. Eventually, the chain of objects reaches a process boundary, which is where the system will find external details such as host names, ports, and user interface events.