There is a great emphasis, and rightly so, on writing software as “simply” as possible. Simple software is easier to understand and maintain, bugs are easier to spot and fix, and its easier to verify that the system behaves as specified. There is a constant tug-of-war between simplicity and complexity. I see three sources of complexity in software:

Complexity inherent in the domain being modeled Complexity introduced to increase system performance. This is fine as long as the performance gains offset the short- and long-term cost of complexity. Complexity created as a result of a system struggling to interact with itself. This should be strictly avoided.

This ideal of simplicity is horribly misused when it is used to defend intellectual laziness. When faced with a difficult design decision, we must be very cautious when we claim that an idea we came up with or an idea we’ve used before seems simpler than something new.

Simple is not the same thing as familiar. Simple and familiar designs have in common that it takes us less mental work to reason about them, but that benefit of familiar designs doesn’t apply to anyone but ourselves.

Recognizing that we have a natural bias to seeing familiar designs as simpler, and to seeing unfamiliar designs as a terra incognita of hidden complexity, what are some more objective criteria for calling a design simple?

Designs that correspond to the end-user mental model

Imagine you have an application that allows users to send messages to each other. In meetings, stakeholders say things like “We want users to be able to save draft messages.” When users contact Support, they say things like “I sent a message to my friend, but it never arrived.”

Everyone involved is convinced that there is such a thing as one user sending a message to another, so a simple implementation of this would be something like:

Message.new(user1, content).send_to(user_2)

With this implementation, stakeholders and engineers use the same lingo. Meetings go smoothly, without any need to translate to and from the language of the domain objects. When engineers are building the system, they can more easily (even unconsciously) anticipate future requirements and build abstractions that will support them.

Contrast that with this implementation:

ConveyanceFactory.new(:message, content).add_node_with_vector(user1, user2)

For whatever reason, you’ve decided that there is a graph of conveyances, which are abstract entities that convey information such as messages, images, files between users. Now sending a message is as simple as adding a node between these users that contains the message, and connecting them a vector to indicate the direction that the message is going.

Now your meetings are very confusing. When the stakeholders ask for the “draft” feature, it sets off a volley of impenetrable engineer-speak as they debate whether a draft message is a conveyance with a node connected back to the same user, or a node with directionless edges between the sender and receiver. This may be fun for the engineers, and produce a lot of beard stroking and beanbag lounging, but its not fun for anyone else.

Keep your domain objects as close as you can to the end-user mental model, and there will be fewer surprises, easier conversation, and features that actually ship instead of getting bogged down in philosophical discussion.

Designs that do not directly reference implementation details

In the example above, maybe you really do have a very good reason for representing messages as part of an abstract graph of connections between users. Fine. Use that as a low-level implementation detail that you hide behind an abstraction that matches the more intuitive end-user mental model. Ideally this abstraction will be more than just a thin skin that dives down immediately into the gory details. A system that is sufficiently complex will benefit from having intermediate abstractions that allow for recombination of the various components of your system in novel ways.

If you’d like to learn more about the benefits of deeply layered abstractions, the classic example is the OSI 7-Layer model, aka The Internet. Another cool example is to dive into how git features like branch and merge are built out of lower-level commands. Check out Chapter 5 of Pro Git for more.

Designs that accommodate only the current requirements

We all love to built abstract systems. The idea that we could add entire new features in just a few keystrokes, wowing our colleagues with the nimbleness of the system we architected is a seductive fantasy. Very rarely does this actually pan-out though. Even if the stakeholders say that such features are definitely in the pipe, its usually better to code the first few concrete implementations before going abstract. You’ll probably learn a lot about the domain in doing so, and your abstraction will be more durable if and when its needed.

Designs that allow processes and states to be inspected

Imagine you are designing a system that manages shipments. A shipment can be in multiple states and locations as it is picked up by the carrier, transported across the world, and finally arrives at its destination. If you want to build a “simple” design for this process, you might just have a table of shipments , with columns for location and state . Whenever those change, you can just update those columns.

Then a customer calls up Support and complains that their shipment was lost in transit. What happened to their package? Because you “simply” updated those columns, you have no good way of inspecting the history to figure out what may have gone wrong. Next thing you know, you’re writing a parser to comb through your logs for any events corresponding to that shipment.

Essentially, you’ve “robbed Peter to pay Paul” by keeping your process simple, but making your inspection of that process prohibitively complex. Depending on your needs, it may be simpler overall to add complexity to your data model while gaining a simpler auditing process.

Designs that allow expression of your domain

There’s no need to hamstring yourself with a limited vocabulary in the name of simplicity. In natural language, you could try to express complex ideas using only the 1,000 most common words (xkcd again), but its really hard. Its actually simpler to use words that get at what you are trying to say more directly.

I’ll use an example from a domain we all model: testing software.

describe Shipment do it 'can go from "preparing" to "shipped"' do shipment = Shipment.new( weight: 10, width: 15, height: 21, length: 18, location: 'San Francisco, CA' ) shipment.preparing! expect { shipment.shipped! }.not_to raise_error end it 'cannot go from "shipped" to "preparing"' do shipment = Shipment.new( weight: 10, width: 15, height: 21, length: 18, location: 'San Francisco, CA' ) shipment.preparing! shipment.shipped! expect { shipment.preparing! }.to raise_error end end

You could write tests this way, but you’re going to have a hard time with all the repetition you’re signing yourself up for. By adding the apparent complexity of a more expressive set of functionality ( let and build_shipment ), you end up with a much simpler implementation:

describe Shipment do let(:shipment) { build_shipment.preparing! } it 'can go from "preparing" to "shipped"' do expect { shipment.shipped! }.not_to raise_error end it 'cannot go from "shipped" to "preparing"' do shipment.shipped! expect { shipment.preparing! }.to raise_error end end

Read more about my thinking on expressiveness in tests in this post.

Keep It Simple

Simplicity is not an end in itself, but a useful metric in designing systems that are comprehensible and extensible. Unfortunately, its all to easy to mistake what is familiar for what is simple. Just because you understand one way of doing things, that doesn’t mean that way is simpler. For the sake of good design and out of respect for the people you work with, try to use more objective criteria for evaluating the quality of your designs.