On Restrictiveness in Tech
I was put before an interesting choice recently. I had to decide on a technical extension to a system, more precisely about how two major components in a system interacted with each other.
On one side, we have a component called Model Provider. This component holds a configurable model. Its exact model depends on the environment it is being used in. Think of it as encoding the local laws of physics for a specific world. Given some inputs, the Model Provider can expose a specific model instance.
On the other side we have the Analyzer component. It needs the correctly configured model and some questions and it provides the answer about that specific world.
For the Analyzer to work, the Model Provider needs to work and expose the correct model. Technically, we do not care at all how the Model Provider does that. In that scenario, the Model Provider exists in hundreds of different instances and Engineers can decide which implementation to pick.
As we define the contract between Analyzer and Model Provider, we can choose different kinds of freedom for the implementations of the Model Provider:
The Analyzer may be restrictive, define the data format, provide the inputs to the Model Provider and the point in time when the Model is calculated, managing the Model Provider’s control flow from the outside.
The Analyzer can be slightly restrictive, define the data format, provide the inputs to the Model but not care when the Model is calculated, as long as it is available when needed by the Analyzer.
The Analyzer can be permissive, defining only the data format. The Model Provider defines how to obtain the inputs, when and how it calculates the model.
Now, the interesting thing about it was this: giving more freedom to the Model Provider component came at almost no cost for the implementation of the Analyzer. Not at runtime, not at build time, not in terms of engineering hours. Cost-wise, I wasn’t even able to tell which one would be slightly cheaper than the other.
Engineers get so used to thinking about those kinds of price tags, that you might feel a bit lost if it isn’t a useful decision criterion. So which level of restrictiveness is better? Should you demand more control over how an implementation is working or should you leave everything possible to the implementer. Intuitively, I’d pick option 3, but it got me thinking about restrictiveness and permissiveness in general.
Is Freedom Good?
Is technical freedom generally beneficial? There are quite some examples where we voluntarily give up freedom, and at least some people would agree it’s a good idea. There are the benefits of static type systems, syntax checks, and mandatory patterns of structural data. There’s the isolation of processes, of memory, of networks, . . . I could go on.
Motivators for Limitation
In the software world, how much freedom or restriction is helpful? I have heard plenty of examples of pro restrictiveness from fellow engineers, blog posts, language designers, and college professors. Having a restrictive language design for example can prevent you from writing crazy code. Maybe a config language should/need not be Turing-complete? Maybe you shouldn’t be able to use pointers at all in your System Programming Language, because they are evil and abusable? Maybe a tool should have defensive mechanisms built-in so you don’t shoot yourself in the foot. If you want to shoot yourself in the foot, you should need to try hard before it happens.
I think however it’s important to look at the motivators for restrictions and how they are implemented to form a more precise opinion on them. Take language design for example. There are more reasons than just correctness (= not shooting yourself in the foot) that might motivate restriction. There’s performance optimization. A compiler can do a better job at it with more static limitations for things like types and function calls. But there’s also readability. The other day a colleague mentioned how difficult it was to figure out what shape of arguments you’re expecting in a Clojure function. The lack of mandatory type annotation can complicate that while simplifying other things like extending your software backward compatible.
Real-life scenarios are often complex and technical design is a result of many trade-offs. However, there are many cases where the options are very similar on relevant scales, like the example I mentioned at the beginning.
In those cases, a restriction can still be helpful, but it depends strongly on how you implement it. Let’s first take a look at some negative aspects.
The Downsides of Limitation
Writing Tons of Stupid Code Because of Plain Languages
Heavily restricting your languages comes at a cost: by allegedly avoiding all the crazy code that engineers would write, they often write a bunch of different-crazy code. The first example that pops into my mind is Terraform. Its language, HCL, is JSON with comments and some rather limited expression language on top. It has for loops but they are a bit awkward to use. The only real abstraction is “modules” which are supposed to live in their own repo, so they are heavyweight to create and update. It’s also error-prone to introduce modules into an existing productive code base because you manually have to adapt existing resource IDs to match the module path. Often it feels tedious to use the abstractions and code duplication turns out to be a not-so-bad alternative in comparison.
Note, that these limitations are not necessary by any technical measure. HCL does not have to be very fast or specialized. In fact, almost all of the language is just simple data structures that exist in most modern languages. I don’t know why it is such a limited language. I can only guess that it was either very easy to get started with a small JSON superset or someone was cautious to avoid Turing completeness. But you can still screw up, right? If you configure your for loop the wrong way, you’ll still provision 9001 EC2 instances by accident and steadily automate your financial ruin.
In fact, HCL seems to be such a burden with its inexpressiveness, that folks started to build third-party abstractions on top using stuff like Jsonnet, see for example here.
Make Engineers Use Awkward Jinks
Whenever engineers have to use a heavily limited tool, they start searching for ways to escape the limitation. Compiling Jsonnet into terraform config files is one example. Pretty much every bash script is another: most of the scripts are just composing other non-bash commands and piping stuff around. The amount of work solved by bash itself is infinitesimal, yet crucial because something has to do the composition. That’s of course because bash is horrible at working with any other kind of data than strings. Testability suffers heavily if it isn’t a complete nightmare already. So here, the limitation clearly hurts productivity fast. If your script gets bigger than a couple of lines, the confusion can already be enormous.
Inhibit Unanticipated Reusability
Even well-intended limitations can hurt when users delve into newly discovered use cases. If you design a system in need to support a specific use case, that’s one thing. Imposing artificial restrictions or only thinking inside that one box will kill any synergy with related approaches at its core. The usefulness of some solutions often only becomes evident after you’ve applied things in real-life scenarios. Only then, does it become evident how certain restrictions or increased concreteness can hurt flexibility.
Give the Users Alternatives
So what to do?
First off, analyze if the restriction has crucial technical benefits, such as better performance, improved security, or more precise error messages. If so, weigh it against the other forces at play. Try to document the trade-offs explicitly and try to think about the requirements. Also, try to find alternative ways to achieve a certain property in your solution. If you don’t benefit technically from restrictions, try to investigate the characteristics of the most permissive implementation. It’s very helpful to know the spectrum of limitations. In particular, try to identify:
The minimum level of restriction necessary to ensure useful operation of your solution, assuming that the user applies the solution with the best knowledge and is aware of risks and corner-case behavior.
The minimum level of restriction to provide all the benefits that you desire to see in your solution. This level of restriction is probably higher, so the solution will be less permissive. Often, this one gives better guidance to someone who is learning to use your solution and prevents misuse or accidents better.
Second, if you were able to get a good view of the spectrum, try to design implementations for at least the two levels, but there might be additional intermediary levels. Ideally, you’re able to base the more restrictive implementation on a less restrictive one in the form of abstraction layers.
If the user is working on a very similar use case as the primary intended on, they will probably enjoy using the highest set of restrictions, as this feels “tailored” to their needs. But they will be able to access more flexibility (but also more details to attend to and think about) if they move down a layer.
Best case, the solution will guide users to move within the highest applicable abstraction levels, unless they need to break out of that abstraction. You can warn users to do so by pointing out which of the benefits they’re losing. If the abstraction levels are well-defined, you might be able to document that very clearly for advanced users.
This way, users can get the best out of the guard rails you’re installing in the solution, because they have a way to opt-out.