A common view among programmers is that UI/UX design is thankfully not any of our business. We have dedicated specialists for that who figure it all out so that we never have to think about it. Unfortunately, this viewpoint is incorrect. Why? As a programmer you do UI design almost every day whether you like it or not.
You disagree? Perhaps you don’t realize who the users are that your interface is for: the other programmers working on your code.
There’s a joke that any code that isn’t yours is spaghetti code. Part of the problem is that it’s generally harder to read code than it is to write it, but I think another major reason is that most programmers don’t realize that their code is a UI, and when they don’t treat it like a UI they end up with a shitty one.
Everyone can recognize bad UI when they use it, and working on bad code has the same problems:
- Isn’t it frustrating when you have to click through 6 different things to get to the one part of the app you actually care about? Isn’t it frustrating when you have to include a bunch of boilerplate in order to make small functional changes to the code?
- Isn’t it frustrating when a screen is absolutely covered in information, yet the one thing you want to find isn’t there and it’s not clear how to get to it? Isn’t it frustrating when an object you want to use has 10 public methods, but only 3 of them are useful and if you call them in the wrong order your program crashes?
- Isn’t it frustrating when you input a bunch of info into an app then an error occurs and it throws it all away? Isn’t it frustrating when your code is super permissive when compiling but then throws tons of runtime errors instead?
- Isn’t it frustrating when an app labels its UI with meaningless buzzwords like
“My Solutions”, “Insights”, etc. making it needlessly difficult to learn how
to use it? Isn’t it frustrating when you have to maintain code with names like
EntityOperationManagerand you have absolutely no idea what it is for because each of those words has several different meanings and none of them are specific?
- Isn’t it frustrating when a trivial app takes 10 or 20 seconds to load? Isn’t it frustrating when it takes 10 minutes for your simple app to build so you can test your changes?
The similarities between traditional UIs and code are not limited to their users, they also apply to their design processes. Any UI/UX designer worth their salt will tell you that there’s only one way to develop a good UI: test it. Really test it. It’s not enough to ask people “How do you think this UI should work?” It’s not enough to show people pictures of a mockup and ask if they like it. No, you need real users to actually sit down in front of the UI and try to use it.
And yet many software developers I’ve worked with seem to think that they can just dream up a software design on a whiteboard and it will be perfect. Or they use the software design to develop a working feature themselves, so it must be a good software design. But did you test it? Not automated unit tests, did you test the UI of your software design? Did you show it to other developers who were unfamiliar with it and see if they could figure it out? Did you consider all of the different kinds of users who will be interacting with it: the developer bolting on a brand new feature in 3 months who had no hand in the software design, the developer working at 3AM to track down a crash that is somehow related to your changes, the newbie trying to learn the structure of your code so that they can make good design decisions themselves?
Did you give some thought for those people—your future users—when you were creating your software UI? Or did you make selfish decisions by making your software hard to interact with?
How not to design a better software-UI
Making a software design with a good UI is similar in many ways to designing a good API for a library or web service: you need to think hard about things like data formats, stability, understandability, naming; but this is also a trap. There are other unique aspects to making a good software-UI, and you ignore them at your peril.
They key difference is that while an API is often used by complete strangers who you will never meet, the UI of your software design affects mainly your teammates. It doesn’t need to be nearly as stable, it can (and often must) evolve as the needs of your team/company/product evolve. It can also rely on esoteric domain-specific knowledge, or impose harsh restrictions based on your business needs.
The most frequent mistake I see when developers try to make a good software-UI is that they start thinking about it as an API. “How would we design this if it was an open source project used by thousands of people on github?” This often immediately starts straying into YAGNI territory like, “We can’t make that a hard dependency! What if we need to reuse this component in an environment where the dependency isn’t available?” or “We can’t enforce that at compile time! What if one day we need to reuse this component in a server that needs to reconfigure its components while running?” Avoid the temptation to optimize for flexibility above all else: your goal is to deliver value to your company. Saving on future development cost by having a flexible software design is one way to do that, but it isn’t the only way.
You can deliver much more immediate value by optimizing your design for clarity of understanding, simplicity of use, early and informative error handling; basically, all of the parts of the software design related to how other developers will interact with your code right now, not based on hypothetical unknown use-cases.
A better approach to design
Rather than starting my software designs with an application programming interface (API), I instead like to start developing them with a programmer interface (PI). In other words, don’t try to start by laying out data types and interfaces, instead start by writing the code as if your software design is already done. Imagine you are another developer using your finished software design and try to implement some of your expected use cases.
Let’s call this PI-driven-design, and like test-driven-development it can be a little hard to wrap your head around it at first. Just like how TDD starts by writing failing tests before the implementation is done, you start your PIDD by writing code that can’t compile or run because none of the software exists yet. The advantage of this is that it immediately strips away a lot of the complexity that a whiteboard software design introduces. Rather than creating an explosion of different types and interfaces and data structures and algorithms and design patterns, you focus only on the immediate needs of the users of your software design. Is the code readable? Is the intent of the code obvious by looking at the names and types? Do the initialized objects do enough to seem useful without encapsulating too much functionality? How will errors manifest if the code is wrong: compiler error, exception, silent failure? Will the cause of the error be clear to the programmer?
You can still think about flexibility, but again focus on how your code will flex to accommodate other programmers as opposed to purely hypothetical use cases. Is the code logically organized such that it is easy to find things later if changes are needed? Is functionality encapsulated in a way that small functional changes can be implemented easily? Let’s discuss two concrete examples to illustrate what too much or too little flexibility looks like in terms of a programmer interface.
1. Flexibility über alles
I have met a lot of programmers who, when adding a property to a class, always add public getters and setters for it. In most cases I would consider this user-unfriendly and a bad PI. The only “benefit” is that it provides the maximum amount of flexibility: anything with access to that object can read it or modify it at any time. But this also makes it much harder for programmers to use: any code modification around that property might require refactoring in many other parts of the code, any new interaction with that property might introduced regressions in other code which was already using it. A much more user-friendly choice is to do the opposite: make it completely private. Even better make it private and immutable so that it can only be set once even inside the instance (if your language supports that). This is user-friendly because it is the simplest behavior to understand: if you are working outside of that class you don’t have to think about it at all (encapsulation!), and if you are working inside the class you only have to consider the value it was initialized with (side-effect-free!). Not every software design works with such restrictive properties, but you should fight tooth and nail to give up as little ground as possible. If it must be public, at least make it immutable. Or if it must be mutable, make it only publicly readable not writable.
Making your software design extremely flexible and robust often makes it more verbose and challenging to use and reason about. It is wasteful if that flexibility is based only on personal speculation. You’re spending resources optimizing for a hypothetical situation while ignoring the immediate extra costs you are incurring by making your software harder to use. Most likely your software design needs a programmer interface which hides the flexibility and directly addresses your immediate needs.
2. Useless ease-of-use
I have also met a lot of programmers who frequently use the singleton pattern. When I ask why they are using singletons in their design they usually answer, “This makes it very easy to use my code,” or “We only need one instance of this object.” These justifications sound like they are in line with my previous arguments: taking into account ease of use is very considerate of our users and the singleton pattern is restrictive about instance creation so we’re also favoring simplicity over flexibility!
However, I believe these arguments are twisting the truth. In OOP, singletons are the exception, not the rule, it takes additional work to make a singleton in most languages, and most singleton implementations demand some amount of special treatment when it comes to their use and testing. While those previous arguments are not necessarily false, they omit the key fact that those supposedly PI-friendly benefits come with tradeoffs. To evaluate whether they are truly good in terms of the PI, we must evaluate those tradeoffs.
In terms of ease of use, while singletons are definitely easy to use in implementation they are much harder to use when it comes to testing. In some languages they cannot be mocked at all if you aren’t already using dependency injection, and even when they can be mocked they often require special mocking, which makes it harder to write tests. If you listen to other industry experts, you should have much more test code than application code, so this aspect sounds like it is likely more negative than positive.
How about flexibility? The flexibility argument ignores that while the classic singleton pattern does restrict initialization, it vastly expands access to shared state. Normally if some code initializes an object, it is nontrivial to give unrelated modules of code access to that same object, but not so with singletons. With singletons you open up the possibility that any two areas of code with access to the singleton’s namespace can now have a direct dependency connecting them. Not only that, but this dependency can be an example of tight coupling since often uses of a singleton assume that it is a singleton and thus rely on the fact that the state is shared. Breaking this assumption can be nontrivial. Thus, while slightly restricting flexibility, using a singleton can introduce a huge amount of potential complexity by making it easy to tightly couple your future code in ways that will be difficult to undo. Once again this aspect sounds more negative in terms of the PI design.
The PI-friendly way to view the singleton pattern (and any other design pattern for that matter) is that it is a potential burden on the design. It should only be included if the PI specifically calls for it and it doesn’t have harmful side-effects. As an example, let’s consider the PIDD for a logging feature for an application. We want it to be really easy to call, something like
log.error("something went wrong")
and I want to be able to write that same code in any file and have it work the
same way. Most importantly, I want all of those log messages to end up in the
same place so that I get a unified log file for the whole application. This is a
PI challenge: I need other programmers to easily access that
log object and I
want them to be able to easily understand that all logs will be unified. The
singleton pattern does solve these problems, but most importantly the tradeoffs
it introduces here seem negligible. Most applications don’t bother mocking their
logging interface in tests, in fact, you often use the real logs during testing
to help with debugging. And the only case in which we wouldn’t want the shared
mutable state of logging is if we wanted logs from different components to end
up in completely different log files, which seems like a very unlikely
Unfortunately, I have rarely seen this kind of evaluation occur when deciding what to include in a software design.
Practically, using PIDD in your software designs doesn’t require any major changes to your development process. Usually I use it behind the scenes to inform my software designs and to justify design decisions. So while I still include the usual UML diagrams and flowcharts and such since those are useful tools for communicating about the design, they are not what I start with. I start with PIDD in the form of a very rough proof-of-concept, iterating on that several times until I find something that feels great to interact with as a programmer. Only afterwards do I start on my formal software design. But that’s just how I do it, probably there are other viable ways to make your designs programmer-interface-centric.
The important thing is that when you design your software, start with the users: the people on your team! How will they use the software? Remember that in this context “use” means: reading the code, extending the code, bug fixing the code.