I've been trying to further my knowledge a lot recently and it has taken me out of my comfort zone quite often, but I think it's a great way to learn. One of the things I came across a while back was Data, Context and Interaction (DCI), but it wasn't until I heard Jim Gay talking about it on the /dev/hell podcast that I thought about taking a closer look, particularly back in my comfort zone of the PHP ecosystem. Further reading took me to The Right Way to Code DCI in Ruby, which I used to drive my PHP DCI experiment.
I wont go in to DCI theory too much, but there are lot's of articles around the web, if you fancy some deeper reading, Jim Gay has a book (currently beta) available at clean-ruby.com. I've not read it yet, but people are saying great things about it.
User Stories
Taken straight from the aforementioned post, our story is "As a user, I want to add a book to my cart". We can write a feature for that:
Feature: User adds a book to their cart
In order to purchase a book
As a user
I need to be able to add the book to my cart
Scenario: User adds a book to their cart
Given I am a logged in user
And a book exists
And I am looking at the book's page
When I click the add book to cart button
Then the book should be in my cart
The "Roles"
PHP is not Ruby. PHP is defintely not Ruby. We've not actually written any code and we've hit our first sticking point. Ruby allows dynamic method injection and PHP does not. As such, I'm going to wrap our data with a role and delegate to it. This doesn't strictly adhere to the OOP utopia that DCI requires, but it is in "the spirit of DCI" (see comments in this post). We could come up with some magic solution, such as a trait that allows binding of closures to the data object, but I'm quite happy with the cleanness of this solution, without introducing a lot of userland magic.
Specs first, I'm using my own toy framework for this, I haven't really had chance to checkout phpspec2 and get fully up to speed on it.
<?php
use BookShop\Entity\User;
use BookShop\Entity\Book;
use BookShop\Role\Customer;
describe("BookShop\Role\Customer", function() {
beforeEach(function() {
$user = new User();
$this->customer = new Customer($user);
});
describe("#addToCart", function() {
it("puts the book in the cart", function() {
$book = new Book;
$this->customer->addToCart($book);
assertThat($this->customer->getCart()->hasBook($book), true);
});
});
});
To hide away the actual delegation, I'm using a simple trait, you can see it in the repo;
<?php
namespace BookShop\Role;
use BookShop\Entity\Book;
use BookShop\Entity\User;
use BookShop\Util\Delegator;
class Customer
{
use Delegator;
public function __construct(User $user)
{
$this->delegateTo($user);
}
public function addToCart(Book $book)
{
$this->getCart()->addBook($book);
}
}
Note: I've used a fairly loose interface here, I think ideally
the argument passed to the constructor would need to be something like a CartOwner
,
allowing other types of object (anything that has a getCart method) to become a Customer
.
Maybe we'll have Visitor
objects, for people who don't want to become a
registered user etc.
The "Context"
I hit another stumbling block here. Because we are wrapping our data with our
Customer
role, we create a hard dependency on that class, making it difficult
to mock. There are ways around this, I've seen some implemetations that pass the
Role in to the Context, but I think that definitely goes against the "spirit of
DCI", another way would be to optionally inject a factory into the context,
specifically for testing, but seeing as we're dealing with a fairly simple
interaction, I'll just use concrete instances.
<?php
use BookShop\Context\AddToCartContext;
use BookShop\Entity\User;
use BookShop\Entity\Book;
describe("BookShop\Context\AddToCartContext", function() {
beforeEach(function() {
$this->user = new User();
$this->book = new Book();
$this->context = new AddToCartContext($this->user, $this->book);
});
it("creates a customer", function() {
assertThat($this->context->getCustomer(), anInstanceOf("BookShop\Role\Customer"));
});
it("adds the book to the customers cart", function() {
$this->context->execute();
assertThat($this->context->getCustomer()->getCart()->contains($this->book), true);
});
});
Another slight deviation here, we can't have a class method and an instance method called by the same name in PHP, so I just skipped the class method. Here's the implementation:
<?php
namespace BookShop\Context;
use BookShop\Entity\User;
use BookShop\Entity\Book;
use BookShop\Role\Customer;
class AddToCartContext
{
protected $customer;
protected $book;
public function __construct(User $user, Book $book)
{
$this->customer = new Customer($user);
$this->book = $book;
}
public function execute()
{
$this->customer->addToCart($this->book);
}
public function getCustomer()
{
return $this->customer;
}
}
The "Data"
I won't go in to that here, but our data classes are just POPOs and I use
doctrine to persist them to the database. The relational side of things isn't
perfect (you can only have one copy of a book in your cart), but that's not the
focus of this. Admittedly, if I had a more robust data model (User -> Cart ->
CartItem -> Book
), to make doctrine
fit might mean the Customer#addToCart
method gets a little more complicated,
though maybe that would be extra justification for using DCI and putting that logic in a Role.
<?php
namespace BookShop\Entity;
class Book
{
protected $name;
/**
* Getter/Setters etc
*/
}
Fitting Into Silex
I'm a big fanboy of Silex, so here's how the context gets used in my controller:
$app->post("/cart", function(Book $book) use ($app) {
(new BookShop\Context\AddToCartContext($app->user(), $book))->execute();
$app['em']->flush();
return $app->redirect($app->path("book_view", array("book" => $book->getId())));
})->convert("book", /* fetch book using id in request */);
We create the context and execute it and then flush changes to our datastore
Final Words
I quite enjoyed investigating DCI and in conclusion, despite PHP's implementation shortcomings, I think pursuing the "spirit of DCI" in PHP is a viable pursuit. It's quite possible that PHP's limitations in the architecture side may prove beneficial, in that DCI may be communicated more as the paradigm, separating the domain, the use cases and the roles, rather than just the code injection.