Monday, September 08, 2008

Domain Specific Languages don't follow the Principle of Least Surprise

Ola Bini gets it right, as usual, in Evil Hook Methods?, but I think you can actually take the idea a bit further.

DataMapper allows you to gain it's methods simply by an include statement.

class Category
include DataMapper::Resource
# ...
end

Ola points out that the include method should not add class methods. At least, that's not what it was designed to do. Include should (by way of append_features) add the constants, methods, and module variables of this module to the class or module that called include.

The problem for me is: Should DataMapper add it's methods to your class when you use Ruby methods as they were originally intended, or when you use DataMapper's Domain Specific Language (DSL).

If DataMapper is a framework and should be used traditionally then you should add it's methods in the following way.

class Category
include DataMapper::Resource
extend DataMapper::Resource::ClassMethods
# ...
end

However, you can't blame DataMapper for following the pattern that's been around since long before DataMapper. At this point I would consider the trick to definitely be an idiom even if it is an anti-pattern. The reality is that include has been stolen by those that prefer the simplest possible Domain Specific Language for adding their behavior.

Martin Fowler describes how a framework can have a traditional API as well as a thin veneer that allows you to use the framework in a more fluent way.

Unfortunately, in the Ruby world we've designed our veneer in a way that doesn't allow for traditional usage.

The other day I noticed something that I thought was equally interesting in the Java world. I was working on a test that used JMock and IntelliJ formatted my code as shown below.

    1 class PublisherTest extends TestCase {
2 Mockery mockery = new Mockery();
3
4 public void testNamesAreAnnoying() {
5 final Subscriber subscriber = context.mock(Subscriber.class);
6
7 mockery.checking(new Expectations() {
8 {
9 one (subscriber).receive(message);
10 }
11 });
12
13 // ...
14 }
15 }

Unimpressed by lines 8 and 10, I changed the code to look like the following snippet.

class PublisherTest extends TestCase {
Mockery mockery = new Mockery();

public void testNamesAreAnnoying() {
final Subscriber subscriber = context.mock(Subscriber.class);

mockery.checking(new Expectations() {{
one (subscriber).receive(message);
}});

// ...
}
}

Mike Ward said I shouldn't do that because the IntelliJ formatting properly shows an initializer for an anonymous class. Which is absolutely correct, but I don't want an anonymous class with an initializer, I want to use JMock's DSL for defining expectations. And, while the second version might not highlight how those expectations are set, that's not what I care about.

When I write the code I want to create expectations in the easiest way possible, and when I read the code I want the fact that they are expectations to be obvious. I don't think removing lines 8 and 10 reduces readability, in fact it may improve it. Truthfully, I don't care what tricks JMock uses to define it's DSL (okay, within reason), I only care that the result is the most readable option possible.

Back to DataMapper, I believe there's a superior option that allows them to have both a clean DSL and a traditional API. The following code would allow you to add methods as Ola desires (traditionally) and it would allow you to get everything with one method invocation for those that prefer DSL syntax.

class Object
def data_mapper_resource
include DataMapper::Resource
extend DataMapper::Resource::ClassMethods
end
end

class Category
include DataMapper::Resource
extend DataMapper::Resource::ClassMethods
# ...
end

class Entry
data_mapper_resource
# ...
end

The obvious drawback is if everyone starts adding methods to Object we may start to see method collision madness. Of course, if the method names are given decent names it shouldn't be an issue. It's not likely that someone else is going to want to define a method named data_mapper_resource.

Don't worry. For those of you who prefer complexity "just in case", I have a solution for you also.

module DataMapper; end
module DataMapper::Resource
def self.instance_behaviors
DataMapper::Resource::InstanceMethods
end

def self.class_behaviors
DataMapper::Resource::ClassMethods
end
end
module DataMapper::Resource::InstanceMethods
def instance_method
"instance method"
end
end
module DataMapper::Resource::ClassMethods
def class_method
"class method"
end
end

class Object
def become(mod)
include mod.instance_behaviors
extend mod.class_behaviors
end
end

class Category
include DataMapper::Resource::InstanceMethods
extend DataMapper::Resource::ClassMethods
# ...
end

class Entry
become DataMapper::Resource
end

Entry.class_method # => "class method"
Entry.new.instance_method # => "instance method"
Category.class_method # => "class method"
Category.new.instance_method # => "instance method"
Post a Comment