In “A Philosophy of Software Design,” (ISBN-13: 978-1732102200) John Ousterhout describes the ideal functional interface as “narrow but deep.” That is, it should not expose many methods or functions, but the ones it does expose should be powerful.
I have mixed reactions to this principle, so I’d like to explore some examples that support it and others that argue against it. Throughout this section, my lens is malleability.
First, imagine a somewhat typical Java domain object with a “broad but shallow” interface. That is, it exposes getters and setters for many attributes. That gives it a wide surface area. The functionality provided by those methods is slim. One could argue (and I have) that this is no better than making the object’s attributes public. It adheres to a naming convention that was created for 90’s era GUI builders and the pedantic rule that members ought to be private.
Thin as that Java object’s interface is, it can still inhibit change if any of the members are references to other objects. A caller must navigate a graph of references, thereby coupling to what should be the internal structure of the object and preventing the object from changing those internals. (c.f. The Law of Demeter) I will consider this example as supportive of the “Narrow but Deep” principle, in that we see a clear failure mode of the contrapositive.
Second, consider a more intelligent Java object that does not merely expose attributes but provides behavior beyond “addXxxListener” or “addOrderLine”. It likely has a wider interface, making it “broad but deep”. Would this object inhibit change? Possibly. In this case it largely depends on how much of that surface area any particular caller engages. The broader the object’s interface, the more specialized its use becomes. A very wide interface on an object that is used just once indicates it is exquisitely adapted to its current usage. One would not expect to use it in different compositions. On the other hand, an object with the same wide interface might be used in multiple contexts where the additional breadth indicates affordances added to facilitate reuse. This style would be characteristic of Smalltalk or it’s cousin Objective-C. “Broad but Deep” then sits on the border for me. I think it can work but easily becomes a barrier to change.
Third, let us consider a case that we might call “Narrow but Too Deep.” A very
narrow interface would be something like the Interpreter pattern from the Gang
of Four (ISBN-13: 978-0201633610). An interpreter has basically one method
interpret
which takes an object that supplies instructions. Perhaps the
argument is an AST or even a string. This is very narrow and very deep. How does
it do on change?
Change in the caller is very well handled. The caller can readily supply a
different set of instructions to achieve new behavior. Change within the
interpreter is a more complex question. Changes to the implementation of the
interpret
method are easily done. Callers have no visibility into the
machinery of execution. Changes to the instruction set are more difficult.
Addition to the instructions are easily accomplished. Forward-compatible
modifications to the instructions are feasible (though they may or may not be
easy.) Removal of instructions will be difficult. That is because callers have
absolute freedom to construct their instructions however they like. Thus,
narrowing the instruction set either requires an extended deprecation period for
callers to upgrade, or it requires the ability for the interpreter’s authors to
change the call sites.
When we consider those change cases together, we see that a) expansion is easy; b) modification is possible if it is forward compatible; and c) contraction is a breaking change. These are the characteristics of an interface! We have created a new level in which we’ve defined a broad (and potentially shallow) interface: the instruction set. This should not surprise us–after all the pattern is called “Interpreter” so the fact that we’ve created a new language is implicit in the pattern. It is the interface in that new language which becomes challenging to evolve.
(Best advice about the interface in that language: you are a language designer. Design carefully. Be conservative about additions, because whatever you add will be very difficult to retract.)
We can see this same effect with interprocess communication interfaces as well. HTTP offers a narrow interface: headers, a handful of methods, a URL, and a payload. (The headers are probably the broadest part, especially when you consider their mutual interactions. But most non-browser use of HTTP is restricted to a tiny handful of those headers.) HTTP is too narrow by itself, so application programmers have variously adopted XMLRPC, SOAP, REST, and GraphQL to provide a new level of language atop raw HTTP. Let’s consider REST for a moment.
As a new level, we should think of a collection of REST resources as a language. Indeed, we commonly see resource representations, URL schemas, and response codes defined with an interface definition language (IDL) called OpenAPI. Looking back through previous IPC mechanisms, we always find some kind of IDL, whether it is called as such or not. That IDL in fact defines the new language level. The collection of IDLs in play within the boundaries of set of collaborating applications supplies the grammar of that specific distributed system. Perhaps this is one reason why it is so difficult to achieve a coherent distributed system, because the grammar is amalgamated from many disparate sources that lack an overall design.
Another example of “Broad but Too Deep” would be SQL. If you take a hard look at
JDBC and strip away everything that is just there to construct other JDBC
objects, you have basically two parts: execution and introspection.
Introspection allows Java code to examine the constructs created and consumed by
the SQL language. Ignore that for a moment and consider execution. Executing a
SQL statement from inside an application closely resembles executing it from a
command line. Submit a string to the database and read the results. Most of SQL
reduces to one method: execute
. There are variations that serve only to bridge
between the two language levels: batching, cached statements, query versus
modification. I consider these to be accumulated cruft that are not the essence
of the interface.
Nobody, anywhere would say that using SQL from inside an application makes it easier to modify the system. Instead, as with our Interpreter pattern, we have to consider what the interface is in the new language level. That is, we must design the SQL interface to enhance malleability. One way to do that is to create views for consumers rather than having them tie directly to tables. This is the exact analog of programming to segregated interfaces on objects instead of directly engaging the objects' full complement of methods. Elaborate joins in application code are the SQL equivalent to violating the Law of Demeter.
(Ironically, I usually see this problem solved in the exact opposite way: with a mapping layer on the caller side that makes it even easier to directly couple to the precise table definitions.)
We refer to the structure of tables and constraints in the database as a model, but we could also describe it as a grammar. We can only make statements in the language of the database that the grammar permits. And again, we find that it is easy to expand the grammar (schema), may or may not be easy to modify, and very difficult to contract.
This characteristic of creating a new language level seems to pop up every time we make the interface at one level sufficiently narrow and deep. Then we need to worry about coupling and malleability of the new level.