sidebar | sidebarDepth |
---|---|
auto |
1 |
An object must be useful immediately upon instantiation of the object. No dependencies of an object should ever be allowed to be uninitialized. If any use of any of an object's dependencies results in a nil reference exception, then the object is not useful.
This guide describes the standards and practices for implementation design that are used to build the Eventide toolkit and the examples provided for it by the Eventide team.
Having a basic understanding of the useful objects doctrine will facilitate understanding the design patterns and principles that are commonly employed throughout the various libraries and examples.
A useful object:
- Is usable immediately upon initialization without any nil reference errors resulting from uninitialized dependencies (eg:
NoMethodError, undefined method for nil:NilClass
) - Is behavioral, with state that only directly supports the behavior
- Has a single purpose
- Doesn't have any logic in its initializer other than assigning the value of initializer parameters to the object's instance variables
- Formalizes the difference between initializer arguments and setters, and the circumstances when one is used rather than the other
- Doesn't require a foreign mechanism outside of the class's own namespace (including inner namespaces) to initialize it and its dependencies (a.k.a.: an Inversion of Control container, etc)
- Doesn't invite the passing nils or dummy values to its initializer for the purposes of setting up the object for testing
- Doesn't rely on test doubles (stubs) to disengage dependencies that would cause undesirable side effects while exercising or otherwise testing it
- Relies on telemetry rather than test doubles (mocks, spies) to inspect an object's execution
- Doesn't rely on test doubles (mocks, spies) to be used to inspect an object's execution path
An object's dependencies are initialized by default to a safe, and inert (in terms of side effects) implementation of the dependency's interface.
An object's class interface provides a means of constructing an instance of the object, including the initialization of its dependencies to active, operational implementations of the dependencies' interfaces, eg: an active database connection, an active payment gateway client, etc.
An object may record telemetry about its execution as it is executing. The activation of telemetry instrumentation is optional.
The useful objects repository on GitHub includes an example implementation:
https://github.com/eventide-project/useful-objects/
A dependency has an interface. An object that conforms to that interface (and its semantics) is substitutable for the dependency. That object is a substitute.
There is no notion of primary or secondary substitutes. All values that can be assigned to a dependency attribute are substitutable for each other. No single substitute is considered more real than any other.
There is no real instance of a dependency versus a fake instance. These perspectives are purely circumstantial, and should be drummed out of the designer's mind as quickly as possible.
Substitutability guarantees that all implementations of an interface that respect the interface's contract and intended semantics are no more valuable than any other implementation of the interface, and no less real than any other. All substitutes are real substitutes by the very nature of substitutability.
Leveraging substitutability, ensure that an object's dependencies are initialized to an implementation of the dependency's interface immediately upon instantiation of the object.
The most basic safe and inert substitute for a dependency is a null object.
A null object substitute assigned as a default value of a dependency allows that dependency to be actuated without causing a nil reference error to be raised - which is a foundational tenet of useful objects.
A null object substitute is a legitimate substitute if all uses of the dependency don't cause a nil reference error to be raised.
Null objects can be either weak or strict. A weak null object responds to any invocation sent to it. A strict null object conforms to a specific interface and will raise no-method errors for any invocations that don't conform to that interface
class Something
attr_reader :some_value
attr_reader :some_other_value
dependency :some_dependency, SomeDependency
def initialize(some_value, some_other_value)
@some_value = some_value
@some_other_value = some_other_value
end
def self.build(some_object)
new(some_object.some_value, some_object.some_other_value).tap do |instance|
SomeDependency.configure(instance)
end
end
end
Usage:
# Using the initializer results in null object dependencies
something = Something.new(some_value, some_other_value)
puts something.some_dependency.class
# => #<Class:0x007f9dcc0886f0>
# Using the constructor (factory method) results in operational dependencies
something = Something.build(some_obj)
puts something.some_dependency.class
# => SomeDependency
The initializer typically accepts precisely the primitive data the object depends on directly to perform its behavior.
Any destructuring of more complex objects is provided by a factory method, or constructor, provided by the class interface (the build method, above).
By the time the initializer is invoked, the exact data needed by the object in order to do its work is supplied to the initializer. The initializer is not required to do any other work other than capturing the data as the object's instance variables.
The class constructor provides a convenience interface. It's the interface used to make invoking the initializer easier for the developer by not requiring all of the initializer's individual arguments.
The some_dependency
dependency is not an appropriate initializer argument. It's a collaborator dependency (a better term would be service dependency, but that term is too overloaded to be helpful).
An instance of the Something
class needs the some_dependency
in order to fulfill its obligations at runtime, but it is not necessary to provide the operational implementation of the dependency in all cases (any substitute implementation - due to substitutability - is also a permissible value).
The some_dependency
can be optionally set to an operational instance of SomeDependency
, or to a substitute.
By default, the some_dependency
will be assigned a strict null object that is constructed to conform to the SomeDependency
interface.
A direct invocation of the initializer will not leave the some_dependency
attribute uninitialized as nil
.
The dependency will be assigned the default null object substitute that is constructed dynamically as a function of the dependency
macro. The default null object may be specialized by implementing a concrete substitute.
The value of some_dependency
that is needed in live operation (the operational implementation) is provided by the build
constructor (factory) method.
This constructor (build
) allows for the most convenient use of the class without commingling construction conveniences and machinations with the initializer, and leaves the initializer free of any responsibilities other than capturing only the essential data needed for the object's operation.
It's important to see substitutes as substitutes (in the substitutability property sense) rather than as stubs, or any other test double.
A stub is a concern of testing. A substitute is a bona fide concern of a class's operational design.
While stubs and substitutes may find themselves in similar roles at test time, the perspective that leads a design toward stubs is very different than the design perspective that leads to substitutes.
The goal of a stub is to remove undesirable side effects from the course of execution during a test. The goal of a substitute is to allow an object to be useful upon initialization of the object, without needing to use any other tool setup but the class's own interface.
The use of test doubles relegates the properties of usefulness and transparency to an afterthought of testing.
A test that uses a stub framework is explicitly calling out the design requirement to allow for an object's dependencies to be safe and inert substitutes. These are legitimate use cases of the object and its dependencies that should be accounted for by the design, and the exercise of them in this way is a priori concern of the design.
The term configuration here does not refer to the kind of preference or settings data used to provide system-wide variables during start up of an application or service.
The use of the term configuration here refers to the configuration of an assemblage of collaborators. This is closer to the meaning of configuration more common to the actor model and actor systems. In this context, it refers to the assignment of collaborator dependencies to the objects that depend on them.
The job of configuring operational dependencies falls to the class's constructor (or, factory method), implemented above as the build
method.
Furthermore, it's the job of the dependency's class to decide how the dependency should be constructed, and how it should be assigned to the object that depends on it.
This line of code from the example above is where configuration is happening:
SomeDependency.configure(instance)
This form allows SomeDependency
to decide for itself how it should be constructed and to be assigned.
The simplest implementation would be:
class SomeDependency
def self.configure(receiver)
instance = new
receiver.some_dependency = instance
end
end
This pattern is also an example of Tell, Don't Ask, which is a helpful pattern in preserving encapsulation, which in turn help limit the effects of some of the more harmful kinds of coupling.
In effect, tell the dependency's class to get an instance of itself (in whichever way it does that, which is not the business of the user of the class), and to assign it back to the instance of the object that has the dependency.
While this property might seem negligible, spread over the breadth of an application or system, and over the weeks, months, or years of a work on a system of objects, the cumulative effects of uncontrolled coupling accounts for much of the productivity slow down and increased costs that teams commonly experience.
The above configuration implementation can be minimized with a macro.
Here is the SomeDependency
class's configuration minimized by use of a configure
macro:
class SomeDependency
configure :some_dependency, constructor: :new
end
Be warned, however, that doing this makes the class more obscure to first-glance intelligence - especially in situations where the configuration must be specialized or is more complicated.
It's arguable that pursuing this minimal implementation reduces the usability of this object even while the mechanical details of the boilerplate are reduced.
Most objects in your system should be behavioral. This is a fundamental tenet of Object-Orientation, and really of most programming paradigms.
Behaviors aren't really supposed to be bolted on to data objects (a.k.a.: entities) as secondary features of data objects. But it's often the result when designers and developers see the system's data as the purpose of objects. This is more of a common mental quirk than an aspect of design.
The pervasiveness of Object-Relational persistence frameworks, and the use of objects to represent storage structures - especially rows in databases - exacerbates the realization of objects as primarily concerned with behavior rather than data.
The implication for design is that a good deal of the classes in your systems will reflect the Command Pattern. However, because this is the default use of objects, it doesn't need to be said that an object implements the command pattern. It's enough to say that an object is an object. This is the same as saying that an object is behavioral, or in other words, that an object is a command. In effect, it goes without saying that an object is a command. It should be more rare that an object is a data object or an entity (or an ActiveRecord object).
The implication is that objects rarely need to have "command" or "service" (just another name for a behavioral object) in their name. Subsequently, it's not necessary to have words like "do" or "execute" or "perform" in a class's name, or in any of its methods' names.
There are always exceptions, but they should be rare. If they're not rare, the design of the namespace should be examined for design flaws.
Unlike a data structure, an object is responsible for a single act. That act is set in motion by a single method. That method is termed, "actuator".
It's not necessary to give this method a name like "run", "perform", etc. Ruby already provides for implementing actuators with a method named call
. This built-in can be invoked implicitly as some_object.()
(although some_object.call()
will also work).
Behavioral objects have actuators. The objects' classes also have actuators. This duality parallels the relationship between a class's initializer and its build
method.
Once an object is instantiated, it is ready to be actuated, ie: to have its call
method invoked. As a convenience, the class also provides a call
method. The class actuator constructs the object, and invokes the instance actuator.
The above example elaborated with actuators:
class Something
attr_reader :some_value
attr_reader :some_other_value
dependency :some_dependency, SomeDependency
def initialize(some_value, some_other_value)
@some_value = some_value
@some_other_value = some_other_value
end
def self.build(some_object)
new(some_object.some_value, some_object.some_other_value).tap do |instance|
SomeDependency.configure(instance)
end
end
def self.call(some_object)
instance = build(some_object)
instance.()
end
def call
# Execute the object's raison d'etre, making use
# of the object's attributes and dependencies
end
end
The above primitive initializer is ultimately so simple that it could effectively be replaced with a macro:
class Something
initializer :some_value, :some_other_value
dependency :some_dependency, SomeDependency
# ...
end
In the above case, the initializer
macro would both generate the initialize
method and create the two attributes.
Note: While this is a possibility opened by making sure that initializers only capture initial data and do nothing else, representing an initializer and its attributes with such a macro is a personal choice. It has the drawback of being more obscure and esoteric. Such a thing should be standardized and socialized in a team so that it's self-evident for the developers who experience it.
The ability for an object to provide insight into its own execution is something that should be accounted for by the design, and should be a first class citizen of design.
If the execution of an object is important enough to need to verify the execution in a test, then that transparency is a bona fide element of the design, rather than the responsibility of the user of the object (ie: test code).
Transparency is not of tests. It is used by tests. Transparency is of design.
Said otherwise: the presence of test doubles (mocks, stubs, spies) signifies that an object's design itself is not accounting for the use cases that it is engaged in. The object is being asked to do things that it's not designed for.
There's no doubt that a Ruby object can be brute-forced into such a thing, but that doesn't solve the problem of a class expressing its uses in its own code.
It should be clear to the reader of the class what the class's user are interested in terms of transparency, what telemetry is published during a class's execution, and how the instrumentation is activated.
Here's an elaboration of the implementation that includes a telemetry mechanism:
Usage:
something = Something.build(some_obj)
sink = Something.register_telemetry_sink(something)
something.()
assert(sink.recorded_something_done?)
module UsefulObjects
class Something
attr_reader :some_value
attr_reader :some_other_value
dependency :some_dependency, SomeDependency
dependency :telemetry, ::Telemetry
def initialize(some_value, some_other_value)
@some_value = some_value
@some_other_value = some_other_value
end
def self.build(some_object)
new(some_object.some_value, some_object.some_other_value).tap do |instance|
SomeDependency.configure(instance)
::Telemetry.configure(instance)
end
end
def self.call(some_object)
instance = build(some_object)
instance.()
end
def call
do_something
end
def do_something
telemetry.record(:something_done, some_value)
do_something_else
end
def do_something_else
# ...
end
module Telemetry
class Sink
include ::Telemetry::Sink
record :something_done
end
def self.sink
Sink.new
end
end
def self.register_telemetry_sink(something)
sink = Telemetry.sink
something.telemetry.register(sink)
sink
end
end
end
In addition to having dependencies being initialized to null object implementations, substitutes should also provide a means to override the inert null object with a concrete implementation of a substitute (or to specialize the null object).
Here's an example of a dependency with a concrete substitute implementation (though a naive one that doesn't demonstrate a realistic case):
class SomeDependency
configure :some_dependency, factory_method: :new
def do_something
do_some_destructive_side_effect
end
module Substitute
def self.build
SomeDependency.new
end
class SomeDependency < ::SomeDependency
def do_something
pretend_to_do_some_destructive_side_effect
end
end
end
end
If a concrete substitute also needs transparency, it can be instrumented with telemetry to record and expose the details of its execution:
Usage:
something = Something.new(some_value, some_other_value)
some_dependency = something.some_dependency
sink = SomeDependency.register_telemetry_sink(some_dependency)
assert(sink.recorded_something_done?)
class SomeDependency
dependency :telemetry, ::Telemetry
configure :some_dependency do
new.tap do |instance|
::Telemetry.configure(instance)
end
end
def do_something
do_some_destructive_side_effect
end
def do_some_destructive_side_effect
# ...
end
module Telemetry
class Sink
include ::Telemetry::Sink
record :something_done
end
def self.sink
Sink.new
end
end
def self.register_telemetry_sink(something)
sink = Telemetry.sink
something.telemetry.register(sink)
sink
end
module Substitute
def self.build
SomeDependency.build
end
class SomeDependency < UsefulObjects::SomeDependency
attr_accessor :sink
def self.build
new.tap do |instance|
::Telemetry.configure(instance)
end
end
def do_something
telemetry.record(:something_done)
pretend_to_do_some_destructive_side_effect
end
def pretend_to_do_some_destructive_side_effect
# ...
end
end
end
end
Note: The registration of telemetry can also be done during the construction of the substitute.
Illustrated by both the class constructor and class actuator, a class interface is a convenience affordance. It allows for instantiation or actuation of an object in a way that is most convenient to the developer.
The instance interface is structured to express the greatest extent of precision and exactness without consideration for developer convenience.
The object interface is correct. The class interface is an ease of use provision that does the work of providing more exact and precise arguments to the object interface.
The class interface insulates the object interface from the encroachment of imprecision that comes from a developer's desire to have ease of use.
For example, it's common (while imprecise and incorrect) for Ruby developers to pass a hash of values to an initializer, and then copy the hash's values to the instantiated object's instance variables. This would be an example of an initializer that does not offer the precision appropriate to an instance interface.
Instead, an initializer should only receive exactly the data that will be assigned to the object's instance variables, without any destructuring of more complex objects, like a hash.
By providing a class constructor, the initializer's precision can be preserved, while also providing a convenience on the class's interface that can destructure the hash and invoke the initializer with the appropriate level of exactitude and precision.
The following example illustrates the principles:
class Something
def initialize(some_arg, some_other_arg, another_arg)
@some_arg = some_arg
@some_other_arg = some_other_arg
@another_arg = another_arg
end
def self.build(hash)
new(
hash[:some_arg],
hash[:some_other_arg],
hash[:another_arg]
)
end
end
Boilerplate can be seen as clutter that is obscuring the deeper meaning and purpose of the implementation. However, there's a tipping point beyond which further reduction of mechanical code makes the object's operation incomprehensible.
While counter-intuitive to many developers, the reduction of in the amount of code is usually not a factor in increasing productivity. In fact, the opposite is true, and is often plainly observable through static analysis of design and code.
The unavoidable side-effect of reducing certain kinds of boilerplate is an increase in abstractness. With an increase in abstractness comes the increase of afferent coupling to abstract members, and with that comes the rigidity that has a compounding effect on the time it takes to get work done. Abstractness is not a gift that keeps on giving. It's a specific countermeasure for a specific set of problems. Used outside of those problems, it creates more problems than it solves.
It can't be over-emphasized that the things that need to be done to a design to reduce mechanical boilerplate code can be as harmful to design as they are helpful.
There is only a certain amount of boilerplate reduction that can be afforded before efforts to reduce boilerplate create more subtle, but more costly and more entrenched problems.
Seeing the same patterns of code repeatedly can trigger the Don't Repeat Yourself instinct. Deeper inspection of design factors as well as the Don't Repeat Yourself adage itself can show that boilerplate reduction is not the intention of the guidance provided by the adage.
Rather than reduce mere boilerplate code, look for variations on patterns and see if you can eliminate them. Even if the result is that the same pattern is repeated throughout your code. It's the special variation that is more costly to work with.
Any programmer at any level can reduce perceived duplication. It's not difficult, and it's not an unassailable goal of design - except when it does not create countervailing problems. And that assessment is a matter of a judgment call that has to take into effect the unique conditions of the particular system being worked.
If you reduce duplication of code patterns indiscriminately, you'll end up creating a framework from which critical business logic cannot be extricated when the framework becomes too cumbersome to continue justifying its use. While not all frameworks end up facing this fate, it's far more common than not, as framework developers struggle to maintain adoption and relevance as time passes by adding more features and specializations through abstraction rather than by extension (ie: plain old vendor lock-in).
In effect, the abstractions created by the premature boilerplate reduction can cause the use of the programming language to diverge so far from the language's own foundations that learning the framework becomes an exercise in effectively learning a new language. This in itself is an example of the kind of special variation that must be rigorously controlled rather than automatically indulged.
While having to write boilerplate code can seem tedious and annoying, that's all it is. The avoidance of tedium is not a sufficient risk to the design's structural qualities to warrant its indulgence reflexively.
Necessary, irreducible boilerplate is just one of those things that we need to face as programmers with increased patience and a shift in focus from short term gains to long-term sustainability and continuity.
Boilerplate - as long as its instances in various classes have avoided variation, and as long as consistency is rigorously protected - is a very minimal impact on productivity and quality.