Disaster Strikes! A Design Dead End

In incremental development, we grow a system by adding capabilities one at a time. Our bet is that we won’t often hit a dead end, but if we do, the time we’ve already saved will let us dig our way out.

Elm Interpreter 101

I’m writing an elm interpreter in Swift, and I hit a (small) dead end the other day. The elm interpreter evaluates an expression and presents the result in the form value : type, e.g., 7 : number

My approach is a classic Interpreter pattern: build a tree of nodes, then evaluate them. The code to print a node looks something like this:

func asString() → String {
  return "\(value) : \(type)"         // uses string interpolation
}

I used this approach for several simple types (number, Float, and Bool) as well as other types of expressions such as “if”. Simple types can be results; the other types use the string value for testing or debugging purposes.

The Dead End

I had started developing a tuple, an anonymous record, e.g., (3,4) or (1, (False, 3.14)). It turns out that returning a string with both the value and its type already together is a Bad Idea.

The fix is not hard, but it requires coordinating changes in a number of areas at once. (That need to coordinate multiple places is common with dead ends: you’ve built on top of assumptions, and when an assumption changes, its consequences often change too.)

The Code and Tests

Here’s a view of the code:

Code is: new tuple code I'm adding, existing code that relies on the old behavior, and existing code that is independent of old or new behavior

and here’s a view of the tests:

Tests are: new tuple tests, existing simple value tests that capture old behavior, other tests that rely on the old behavior, and tests that are independent of the behavior that's changing.

It’s easy to have top-level code print the value and type separately:

let result = expr.evaluate(context)
print("\(result.asString()) : \(result.type)")

It’s also easy to modify the old tests and code to return just the value in asString(). Easy, but it affects multiple node types that need to be changed.

It’s more problematic to deal with the tests that rely on the old behavior. You can easily find them – they break when you change the asString() method. And there can be arbitrarily many of these tests.

Making Test Independent of Lower-Level Behavior

One way to make tests more independent of low-level behavior is to apply a pattern from James Shore’s “Testing Without Mocks” – Collaborator-Based Isolation. [See References.] Rather than use a literal for the simple expressions’ output, express the expected result as a function of the lower levels’ output.

For example, this assertion:

XCTAssertEqual(
  ifNode.asString(), 
  "if (True : Bool) then 3 : number else 4 : number")

becomes

XCTAssertEqual(
  ifNode.asString(), 
  "if \(condition.asString()) then \(lhs.asString()) 
else \(rhs.asString())")

It’s not a perfect solution: it’s less readable; it’s a bit more work; it makes us look deeper into the implementation. But, it makes the test independent of the lower-level methods’ output, and that’s something we want.

Three Approaches

The core challenge is that we need to coordinate changes: all or nothing for a number of elements. We’ll look at one non-incremental and two incremental approaches.

1. Just Make It Work (Non-Incremental)

An old car with its parts lying on the ground
Photo courtesy of Nation’s Capital Model T Club.

Take the system apart, fix what you need to, and put it back together with the new behavior in place. While the change is in progress, you won’t merge in to the mainline. You’ll probably work in some sort of experimental branch in source control. If you’re looking at days or weeks of work, you’re cut off from changes others make, meaning your code may end up worse than it otherwise would.

The problem is – the changes can be an arbitrary amount of work. And having a change like this block other work hurts your responsiveness to your customer.

For small changes (an hour or two), this is an easy way to go. For bigger changes, it’s tempting but I don’t recommend it.

Tradeoffs: It works but you’re blocked from the mainline while you work on it. If the changes are harder than you thought, you may find yourself needing to (or wishing that you could) take one of the other approaches.

2. Global Boolean Flag

In this approach, create a global Boolean flag to tell you whether to use the old or new approach.

let use_new_version = false         // or true

Use this flag to control whether you run the old or new production code.

if use_new_version {
  // new way
} else {
  // old way
}

You can do this in your tests too, if they get different results in the two modes.

Run production with use_new_version false. Run your tests twice, with both true and false.

Once everything works in a test environment with the flag set true, you can set it true in production. Now your old-behavior code is dead – remove it and then the flag itself.

Tradeoffs: This makes for ugly code, but it lets you work incrementally.

3. Pluggable Support

This final approach makes the behavior “pluggable” – easily replaceable.

Use the Strategy pattern for any behavior that must change. [See Design Patterns in References.] In many languages you can pass in a function reference or a closure rather than introduce a new Strategy type.

At startup, you configure things with either OldWayStrategy or NewWayStrategy. When you call IntegerValue’s asString(), it just delegates to the strategy, with no concern about which strategy it’s using.

A node with plug-in behavior by using the strategy pattern.

Other patterns such as Factory or Abstract Factory may work comfortably with this too.

Your steps are:

  1. Configure each behavior that must change to use a plug-in strategy (or function) with its original behavior.
  2. For production, configure the system to use the original-behavior strategies.
  3. For testing, test both ways.
  4. One at a time, provide a new strategy to plug in (and update the initialization procedures). When this works in test mode, you can check it in.
  5. Once all new strategies are plugged in and working in test mode, you can switch production to also use the new strategies.
  6. Now, all the old strategies are dead code; you can remove them. Once they’re gone, you don’t need the Strategy mechanism any longer.

Tradeoffs: This approach requires more setup than the others, though it may be able to leverage any A-B testing mechanisms you use. It does prevent “if” statements being sprinkled all over. Bottom line: it also lets you work incrementally.

True Confessions

I confess – I used the first (non-incremental) approach the other day. It wasn’t a big change, and it was in an easily visible area (output strings of my nodes). The whole change took less than an hour.

I should have taken the opportunity to use the Collaboration-Based Isolation pattern; I remember being surprised by the tests that weren’t testing asString() but were dependent on its results. If I’m ever changing those tests again, I’ll introduce that pattern.

And next time, I’ll use one of the incremental approaches:)

Conclusion

Incremental design is not immune to dead ends. When we hit one, we have to backtrack on our design. This is challenging because we have multiple areas that need to change simultaneously.

We looked at three approaches:

  1. Just make it work – non-incremental, and not recommended for changes of even moderate size.
  2. Global boolean flag – uses the flag to create a “test mode” where you can implement the new approach in parallel with the old.
  3. Pluggable support – similar to the flag, but uses the strategy pattern (or plug-in functions) to let you control whether old or new behavior is used.

Next time you’re confronted with the need to make multiple simultaneous changes, to introduce a new way in parallel with the existing system, try one of the incremental approaches.

References

Design Patterns, by Erich Gamma et al.

Testing Without Mocks: A Pattern Language“, by James Shore. In particular, “Collaborator-Based Isolation“.