Saturday, October 29, 2011

A simple API

I've been watching Rich Hickey's talk on simplicity, and trying to take it to heart. He's right: easy is easy, and simple is hard. It takes a lot of work, and careful use of the right tools, to end up with "simple."

So here's one tool (dare I say pattern?) that might be useful in building a simple API. I'm building on some posts and IRC conversations that I've run across, so most of this is not original with me, but I'm writing it down here for future reference.

In the spirit of classic "design pattern" methodology, here is the scenario we're trying to address. We need to write some functions that refer to some kind of state. The specific example I'm using is connecting to an IMAP server: I want to establish a connection, grab some message headers, selectively grab the message contents, and so on. Obviously, I don't want to negotiate a separate connection for each and ever IMAP operation I write code for. I want to connect once, and then share that connection with each of the functions that needs to use it.

There's two approaches we could take here: we could define the connection data as, say, a map, and make it the first argument to each function, like this:

(def conn (mail/connect {:host some-host, :user some-user, :pass some-pass}))

(def folders (mail/get-folders conn :default))
(def unread (mail/get-messages conn folders :unread))
; ... and so on

So each function (except the original "connect" function) takes a conn object as its first argument. The alternative approach is to use bindings to wrap our function definitions in a "with-foo" function of some sort, like with-out-str and with-bindings do.

(with-mail-connection {:host some-host, :user some-user, :pass some-pass}
  (let [folders (mail/get-folders :default)
        unread (mail/get-messages folders :unread)))

The second approach is easier to write, and you're less likely to have bugs that result from forgetting to pass the right arguments in the right order. But what happens if you want to write an app that, say, copies messages from one mail server to another? You'll need two different connections inside the (with-mail-connection) expression, and that just won't work, and the work-around would be awkward.

The solution is to use two namespaces, say imap.mail and imap.mail.impl. The imap.mail namespace defines the with-mail-connection function, and a number of functions like get-folders and get-messages that use the binding defined by with-mail-connection. The imap.mail.impl namespace would define the same functions, with conn as the first argument. Then the imap.mail functions would just call the imap.mail.impl functions, passing in the connection as the first argument. The real nitty-gritty details would thus be implemented in the .impl namespace, and the simpler namespace would just be a wrapper to provide syntactic sugar.

This approach is part of a more general pattern of providing a 2-layer API, with an "easy" layer that corresponds to what you want to do, and an "impl" layer that gets into the nuts and bolts of how you actually do it. This approach helps keep the primary API "simple" by insulating the developer from needing to knowing too much about the implementation details.

That's the theory anyway. I'm going to try this with my nonomail project and see how it works out in practice.

1 comment:

  1. This is a wonderfully approachable example of what sort of thinking process that goes into good/simple design. Thank you! I'm bookmarking it so I can refer to it later when demonstrating the concept.

    ReplyDelete