Design is about making choices. When you do design, you narrow the design space down successively until you have found a space narrow enough that the recipient of the design can grasp it and make safe and effective use of it easily.
Whenever you make a choice—and remember, this is the essence of design—you're taking choice away from the recipient. Hence, good design is about making choices that the recipient benefits from and will therefore not feel motivated to second-guess. Bad design is anything that isn't good design. You can't wiggle your way out of it: While there are many different degrees of goodness, refusing to do design at all is generally bad design. If you leave a choice to the recipient that they don't care about, you haven't lived up to your responsibility as a designer, as you have made it harder for the recipient to use your product effectively, forcing them to make arbitrary choices that can only make things inconsistent and end up in disaster. You have pushed the recipient into the Pit of Despair.
Quick: Which of the following two APIs for a string→int key--value store do you prefer, and why?
// ------- API #1 ------- class transaction { int get(string); void put(string, int); void del(string); void commit(); //will abort when it goes out of scope unless you commit() first }; class int_db_1 { unique_ptr<transaction> begin(); };
// ------- API #2 ------- class int_db_2 { void begin(); void commit(); void abort(); int get(string); void put(string, int); void del(string); };
I do not know about you, but even without considering the
question of whether int_db_2
does or does not permit
the use of get
, put
, and del
outside of a transaction, it is obvious to me that int_db_1
is the better design. Why? Because int_db_2
is a
pit-of-despair API. It forces you deal with choices that you
don't really care about, and which you will invariably get wrong
at some point. For example, you can easily fall into the trap of
doing something like this:
void bump_access_count() { // Assume that db is declared in some kind of enclosing scope // (e.g. it is a field in the class that this is a method of). db.begin(); int old_access_count = db.get("access_count"); if (user_not_yet_seen(current_user)) { db.put("access_count", 1+old_access_count); remember_user(current_user); } db.commit(); }
Consider what happens when remember_user
throws an
exception. Will this commit the transaction? No. (So far, all
is well.) Will it abort the transaction? No. So most
likely, the DB object is now in a problematic state. It might
still hold a connection to some backend database service and hog
resources that way. It might fail to start a new transaction when
you call begin
the next time. In any case, you have
introduced a bug—and not only did you introduce a bug, you did it
by being extra careful and using transactions (which is supposed
to be the right thing)! Because if you hadn't been using
transactions in the first place, this wouldn't be a problem.
Maybe you'll avoid transactions next time. And suddenly you'll
get weird behavior because multiple processes are accessing the
same database and seeing inconsistent data. You have fallen into
the Pit of Despair.
int_db_1
, on the other hand, doesn't let any of this
happen. It won't let you think about whether to use transactions
or not. It simply forces you to do so. It won't let you neglect
(or refuse) to handle exceptional cases. It simply forces you to
either commit or abort (by aborting automatically if you don't
commit). There is no way to mess up accidentally. And if the
author of the transaction class has taken care to prevent copying
and moving transaction objects, you can't even realistically cheat
your way out. Sure, you can make a wrapper and pass it around, or
you can use low-level memory manipulation tricks to get at the
internals of a transaction object and mess with them, but if you
do any of that, you have explicitly taken responsibility for the
disaster such a kluge might cause. Surely, if you try, you can
break it. But you can't accidentally make a mess. You
have been pushed right into the Pit of Success.
This is what design is about. When you do design, be it a UI or an API or anything else that requires making choices, make them. Don't let the user make a mess. If the user accidentally misuses your API, it's not because they are stupid. It's because your design just isn't good enough. Take the opportunity to learn from the user's mistakes and improve your design. Not only will it make your users happy, it will also inform your future design. A Pit of Success if ever there was one.
Comments
In theory, I fully agree. It might be a good thing to have several documented "layers" of an API, though, like one where you do not have to deal with transactions, and one where there are transactions and more fine-grained control, in the above example.
Isn't part of the unix design to let people accidentally do stupid things if they want to? (I can remember an example of "tar" overwriting a file it should not due to a small typo in the Unix Hater's Handbook.) Doesn't Git also have this design? So, in practice, does really anyone care?
Submit a comment
Note: This website uses a JavaScript-based spam prevention system. Please enable JavaScript in your browser to post comments. Comment format is plain text. Use blank lines to separate paragraphs.