Architecture Review: Mock-Driven Three-Layer Architecture

Last week, I posted an Architectural Design Challenge. Ralf Westphal and Justin Bozonier were both good enough to respond with some intriguing approaches, and I hope to review their submissions soon. First, though, I wanted to establish a baseline implementation: a classic mock-driven three-layer architecture. (Those links all lead to source code.)

The Mock-Driven Three-Layer Architecture

I'm not a mockist (see the sidebar), so let's start with solid ground. A classic three-layer architecture consists of a User Interface layer, a Domain layer, and a Persistence layer. Each layer is only aware of the layer below it, so the UI layer talks to the domain layer, and the domain layer talks to the persistence layer.

Since the UI talks to the domain layer, not the persistence layer, the domain layer puts a domain-centric face on the persistence layer concepts. Although the persistence layer might provide methods like insert(), update(), and query(), the domain layer will hide those details behind methods like save() and load().

The problem arises when you want to test this code. Naturally, when you test your persistence layer's insert(), update(), and query() methods, you'll want to make sure you're actually putting bits on a disk. It's central to what those methods do. But when you test your domain objects' save() and load() methods, you don't want to test all the way down to the actual database. Those bits are a pain to set up and tear down and they're slow, too. Orders of magnitude slower than testing memory.

Enter mock objects. Mock objects (combined with dependency injection) allow you to replace real objects with objects that are just for testing. They check that you called the methods you planned and they return pre-specified values when necessary.

A mock-driven approach goes a few steps further. The mock-driven approach starts with an end-to-end test and works from the outside in. At first, all but the top layer is mocked out. At each step of the way, your test identifies the collaborators that need to be written next (which are implemented entirely with mocks, to start with). Each mock is successively replaced with a real implementation, which causes more collaborators to be identified, and so on until the code is finished. Proponents say that this approach makes the design smaller and simpler.

Let's see.

Truth in Advertising: I'm Not a Mockist

I don't actually use the mock-driven approach on my projects. When I first heard about mock objects in 2000, I used them heavily on one project, but they confused my colleagues and inhibited our refactoring efforts (in part because the tools back then weren't as good as they are today). I've chosen other approaches ever since. In Martin Fowler's terms, I'm a classicist, not a mockist.

I think I understand the mock-driven philosophy fairly well, but in tweeting about my preparations for this article, I got some disgruntled responses that implied I wasn't up-to-date. Nobody responded to my requests for specifics, though. Hopefully any major mistakes will be corrected in the comments.

The definitive resource on the mock-driven approach is the recently-released Growing Object-Oriented Software, Guided by Tests, by Steve Freeman and Nat Pryce. I haven't read the book yet, but the authors are the inventors of mock objects and leaders of the mock-driven school of thought. It's sure to be a good read.

The Critique

You can find the example code referenced in this critique on Github. To critique the code, I'll use the criteria set out in my design challenge. To recap, I'll be judging the code based on test design, coupling and cohesion, simplicity, and "Quality Without a Name."

(I only did part one of the challenge for this example.)

Test Design

Criteria:

  • All code is tested. (See my book for precise definitions of the terms unit test, focused integration test, and end-to-end integration test.)
  • The test code is as high-quality as the production code.
  • All business logic code (that is, the ROT13 code) is tested with unit tests that don't do any I/O.
  • All integration code (that is, the file and console I/O) is tested with focused integration tests that validate that the I/O libraries each work as expected, independently of the rest of the system.
  • End-to-end integration tests (that is, tests that check the system as a whole) are kept to a minimum, or better still, entirely unneeded.

With one major flaw, this architecture leads to excellent tests, thanks to the heavy use of mocks. Integration tests are only used where necessary and unit tests abound. Even exceptions are easily tested, as illustrated in _UITest:

There is that flaw, however. Because each object is only tested in relation to its close collaborators, and because mock objects are injected throughout the system, it's easy to create code that passes all of its tests but isn't actually wired together correctly for production use. I made that mistake while coding the example and didn't discover it until I wrote my end-to-end test.1 In order to make sure everything is wired up correctly, good coverage with end-to-end tests is essential.

(1The eagle-eyed reader will note that I didn't write my end-to-end test first, as is done in the mock-driven style. It's true: I didn't learn all of the details of the mock-driven style until most of the example was complete. The final result is equivalent, though, as far as I know.)

This is a significant flaw because end-to-end tests suffer several big problems: first, getting good test coverage requires a lot of tests. Second, end-to-end tests run very slowly. (A project I'm currently working on takes five minutes to run a single end-to-end test!) Third, end-to-end tests are expensive to maintain: they're complicated to set up and verify, and they tend to break when any part of the system's externally-visible behavior changes.

Verdict: While the mock-driven approach leads to great unit and integration tests, this advantage is offset by a reliance on end-to-end tests.

Coupling and Cohesion

Criteria:

  • The code reflects the usage and the external behavior of the system more than it reflects the techniques used in coding, so that any concept (such as "loads and saves files" or "rot13") can be clearly related to specific code.
  • Code related to a single concept is grouped together.
  • Code for unrelated concepts (such as "rot13" and "file handling") is stored in separate locations.

The mock-driven approach does a great job of separating concepts from implementation--to a fault, in fact, as we'll see when we look at simplicity. When it comes to keeping code decoupled, though, this architecture excels. Take a look at this partial list of classes:

Each class clearly relates to a specific concept, and the concepts are expressed in the language of the system rather than the language of the technology.

The architecture is pretty cohesive, too, with one Achilles' heel: it doesn't easily allow you to put static constructors on a class, because static methods can't be mocked out (as far as I know). Instead, the static methods must be put into a separate factory class.

For example, Rot13String has a saveAs() method that saves the current string to a file. You might expect it to have a static load method as well, perhaps like this:

That's a pretty nice idiom, but it doesn't work with the mock-driven approach. Instead, the load method is in a different class. In this case, the awkwardly named Rot13StringFileSystemLoader.

Verdict: The mock-driven architecture is nicely decoupled and allows you to express your concepts cleanly. Cohesion isn't a problem either, with the exception that static methods have to be moved to a separate class.

Simplicity

Criteria:

  • Classes' public interfaces are clear and easy to understand.
  • Individual lines of code read well.
  • Boilerplate code is kept to a minimum.
  • Lines of code are kept to a minimum.
  • There is no code that anticipates part two of the challenge.

Simplicity isn't this architecture's strong point. The mock-driven approach emphasizes using interfaces to express "roles"--and, in fact, the JMock framework used in my example will only mock interfaces. This leads to most classes requiring an associated interface--nearly doubling the number of files.

Remember that list of classes (above) that showed how nice and clean the concepts were? Here's the actual list:

Many of those files are completely content-free. Although they might be useful abstractions in the future, they're just not needed now. They're a maintenance burden. The role-based naming makes finding the real code harder and obscures the relationship between ham ("Rot13String") and spam ("TransformableString").

In fact, Rot13String is an example that's particularly bloated. It's turned into four classes (Rot13String, TransformableString, Rot13StringFileSystemLoader, and Rot13StringLoader) because the static load() method had to be moved to its own class and both classes needed an interface.

The interfaces also make it harder to create good public APIs for your classes. Because the interfaces are abstracted to roles, the methods must be abstracted, too. So instead of the descriptive FileSystem.readFile() or even readAllTextFromFile(), we have the vague PersistenceMechanism.read().

Creating mocks also takes several lines of boilerplate code. This is worse when combined with the three-layer architecture's approach of encapsulating the persistence layer behind the domain layer. Each domain object has a few single-line method calls that just delegate to another object. Each one takes half a dozen lines of test code. Here's an excerpt from Rot13String's tests. This tests two lines of production code:

In addition to requiring a lot of code, you can also see duplication with the production code. The first test above specifies that saveAs() will call oneOf (_fileSystem).overwrite("filename", "abc"). The production code is written as _fileSystem.overwrite(filename, _string);. There's not a lot of value-add in that test, especially when you consider that it's just wiring code--and production dependency errors aren't tested.

But wait, there's more: mocks use dependency injection heavily. That leads to a lot of boilerplate constructors that take a dependency injection parameter. This wasn't too bad in practice, although there was a funky bit of code to construct the graph of object dependencies in the top-level UI class constructor:

Verdict: The clean separation of concerns and clear domain language is obfuscated by unneeded interfaces and premature abstraction. Tests can be oversized, particularly when simple delegation is involved, and sometimes duplicate the implementation. Dependency injection adds to the verbosity.

Quality Without a Name

The mock-driven three-layer architecture leads to some beautiful classes that reflect system concepts nicely. It's too bad those beautiful classes are mixed in with a bunch of noise.

In addition, the three-layer approach of hiding the persistence layer behind the domain layer doesn't work well with the limitations of the mock approach. It adds more dependency injection noise and the idiom of putting static constructors on the domain objects doesn't work at all. The methods that just delegate to the layer below require a lot of test code and provide little benefit in return.

Overall, this is a solid architecture with room for improvement. The great encapsulation of concepts makes up for a lot of flaws, and I suspect many of the noise issues fade into the background as the team gets used to their codebase.

If you liked this entry, check out my best writing and presentations, and consider subscribing to updates by email or RSS.