Blog

The Four Fs of Code

Fast, flexible, familiar, friendly

Published
Written by
David Browning

Introduction

Good software is harder to define than working software. A program can compile, pass its tests, and ship to production while still being painful to use, risky to change, difficult to understand, or hostile to maintain. Fix one problem carelessly and two more can appear, like a Hydra with a bug tracker.

Engineers have plenty of tools for dealing with that complexity. We have patterns, principles, frameworks, style guides, profilers, linters, test suites, and years of accumulated scar tissue. Those tools matter, but no pattern can make every decision for us. At some point, every project runs into a tradeoff that does not fit neatly into a template.

The 4Fs are my shorthand for reasoning through those tradeoffs. A good system should be fast enough to respect people’s time, flexible enough to survive change, familiar enough to be understood, and friendly enough to serve the people who use and maintain it. They are not hard rules. They are questions I use to keep a system honest, from the product goal down to the helper function.

Fast

These four principles are not ordered by importance. I’m starting with Fast because it is the most concrete of the principles. I think everyone is on the same page that software needs to be fast. When using a system, users should not wait around for it to “think” like a machine from a different era. The user interface must stay responsive, and work should complete quickly. Pick the right algorithms and data structures. Depending on the size and type of data, one sorting algorithm may perform better than another. Pick a data structure optimized for the problem you are solving. Maps and dictionaries are good if you need to look up random data. But if your data needs to stay ordered, choose an ordered container or sorted sequence, then use binary search where lookups justify it. Optimize for the shape of the workload, not one narrow operation in isolation.

It’s also about the system “feeling” fast and respecting the user’s time. Buttons should give instant feedback. Keep your user interface thread free and do heavy work on a background thread. Don’t waste the user’s time by burying common actions in menus or showing fancy animations. The novelty will wear off quickly.

This is where engineers fall into a trap, and sometimes we do it on purpose. Software should be fast enough to respect the user’s time, but don’t over-optimize. Profile first. Run usability testing. Measure before you arbitrarily decide “this is slow”. Otherwise, you’ll find yourself hand-rolling loops to save a millisecond that the user won’t notice. Fast may be the easiest F to measure and reason about, but it cannot eat the other three principles.

Flexible

The Flexible principle is where things start getting abstract. A flexible system is hard to measure. Do we even have metrics to measure it? Is it the time taken to refactor a piece of code? Is it how many problems can be solved with one function? Luckily, engineers have a toolbox of best practices to guide building a flexible system.

Component architecture helps us keep parts of the system separate. Model-view-controller separates domain state, presentation, and the code that coordinates user input. We keep inventing patterns to solve the same problem: how to untangle some massive rat’s nest of data, functionality, and interactivity. And different tools solve different architectural problems. You wouldn’t cut the grass with a hammer, and you wouldn’t build a personal website out of microservices. Flexible code picks a pattern that scales with the problem.

All this is still a bit abstract though. Some rules of thumb might guide the flexible pillar in the right direction:

  • Try to use the same block of code to solve different, but related, problems.
    • Find ways to apply new nouns to the same verb. Unix-like systems do this with file descriptors. Files, sockets, pipes, and devices are not all created the same way, but once you have a descriptor, common operations like read(), write(), and close() can work across many of them. The alternative is a bloated vocabulary of special-case functions: read_file(), read_socket(), read_pipe(), close_device(). That may feel explicit, but it makes the system harder to learn and harder to extend.
  • Assume the real world is messier than your model.
    • Treat external input as unconstrained until the domain gives you a legitimate reason to constrain it. Unchecked fixed-size buffers are not just a common attack vector; arbitrary input limits also narrow the problems you can solve. A flexible system does not assume the world looks like the developer’s test data.
    • Names are a simple example. It is easy to model a person as having a first name, middle name, and last name, each made of English alphabet characters. That works for some people, but it fails quickly once the system encounters the real world. Some people do not have middle names. Some have multiple family names. Some use mononyms. Some names contain spaces, apostrophes, hyphens, accents, or characters outside the Latin alphabet: José, Zoë, O’Connor, Nguyễn, Søren, or Александр. Flexible systems need to be ready for valid data that the developer did not anticipate.
  • Find ways to tease out components so they can be tested in isolation.
    • This forces you to identify responsibility seams. For this website, the rendering pipeline is separate from how files are fetched from disk. And the caching mechanism is divorced from both of those systems. Now I can test each of them individually without wondering if one of their dependencies works.
    • Design so you can fall back to another solution. Build your system into components that can be swapped around. If your inventory system leaks Oracle-specific SQL through every layer, switching database vendors becomes a rewrite. This doesn’t mean using the component pattern or dependency injection for every project, but have some way to replace how something works without rewriting anything outside its domain. If a file API changes, I should only update the file-access boundary; the networking code should not know or care.

Flexible software is not only flexible for the developer. It is flexible for the person using it. A calculator that only adds may be easy to understand, but it has almost no room to grow with the user’s needs. Good tools expose useful primitives instead of locking people into one narrow path. They let users combine simple actions, adapt the system to nearby problems, and occasionally solve problems the designer never thought to support.

Familiar

If I sat you in front of LibreOffice, a productivity suite similar to Microsoft Office, and told you to make a missing cat poster, you could probably do it. You can do it because you know Word. You recognize the menus and icons. The verbs (like copy, paste, and print) are familiar. The nouns (tables, pictures, text) are familiar. The adjectives (bold, italics, headings) are familiar. Education and culture have surfaced the design language of word processors to everyone.

Building familiar systems is about designing for your audience. And that audience is not just the customers. You also need to design for the developers who will maintain your system. You are no longer optimizing for the machine; you are optimizing for people. That same idea applies inside the codebase: familiar systems use the conventions their audience already understands.

Write familiar-looking code. Match established rules and conventions. A C++ project with heavy use of STL or POSIX should look like C++. It should not be full of PascalCase classes. Your Java code should not read like C#. Properties are a first-class language feature in C# so don’t use the Get/Set pattern of Java. C# developers expect properties, not methods.

The obvious stuff is naming and code styling but there is much more to a familiar codebase than what a linter enforces. Familiar code exposes a system the user has seen before. If you’re abstracting a real object like a car, then use terms like “steer”, “accelerate”, and “refill”. People who use cars do not reason about the car by SetVelocity or UpdateRotationQuaternion.

User interface design has settled on some common patterns. People know that buttons are supposed to be clicked. People understand “moving” through an interface can be done horizontally and vertically using arrows and scroll bars. Familiar systems act like what we are used to. They have common behaviors so our expectations carry between systems. If you’re writing an Android app, make it look like the rest of Android. Websites should have navigation bars. If a link is clickable, it should highlight when hovered over. These are all design choices that users have a shared understanding of.

Compared to the Flexible pillar, Familiar is less about listing best practices and more about respecting shared expectations. Instead of only asking what problem the system solves, familiar systems ask who the system serves. Familiar systems tap into common expectations. They follow the unwritten rules society has placed on them. Have enough humility to remember the project isn’t for you. Be empathetic to who uses the system. We ultimately build systems for other people.

Friendly

Now we get into the philosophy of good software. This is the curveball that engineers don’t talk about. It’s the human psychology that managers have no way to quantify and measure.

Code needs to be friendly. A human being reads and reasons about it. This doesn’t mean write more comments. Worthless comments can be just as bad as no comments. It means choosing meaningful names, writing self-documenting code, and providing robust error handling. A function must document its assumptions about input and output. Friendly code should not allow the caller to put the program in an invalid state. If you’re writing a templated class that only works on numbers, use compile-time assertions so the user cannot improperly instantiate it.

static_assert(std::is_signed<TickT>::value && std::is_integral<TickT>::value, "TickT must be a signed integral.");

The code base should be organized to encourage exploration. It should be discoverable. Any code file that helps solve a particular problem should be grouped together in a folder or namespace. And there should be some kind of obvious entry points that are the launching point for that namespace. Don’t expose the plumbing if it’s not useful to the caller.

Facades are another way to make code friendly. If opening a network connection, serializing an object, or starting an asynchronous file operation requires twenty lines of setup, most callers should not need to know that. Wrap the common case behind a clear function with reasonable defaults, then expose the advanced options only when the caller needs them. Windows APIs often require large configuration structures, flags, handles, and optional fields. (At least MSDN documents all that) That flexibility is useful, but it is not always friendly. A good facade lets the caller say what they mean without forcing them to assemble every piece of plumbing by hand.

Like all the other Fs, there is more to think about than just the developer. A friendly system invites the user to explore it. It is respectful to the user. But that doesn’t mean that the dialog boxes should say “please” and “thank you”. That’s a whole other can of worms. Friendly does not mean cute like “Oops, the system needs to restart”. The language of a friendly system is neutral, respectful, and self-aware that it is a tool.

Fluffy language and feel-good personifications do not help the user understand the system. Often it just abstracts away what would be helpful in an effort to make the system feel like it has personality. But a system should not have personality in the way a human does. It is a tool and the tool should augment and accelerate work, not slow it down. A friendly program understands that it is a system meant to serve humans. This means the designers should build something that does not patronize or distract.

Next to language, error messages may be the best place to improve the friendliness of a system. Program-halting errors should never be the fault of the user, even if it technically is their fault. People don’t like to feel blamed and showing scary big red text about something going wrong is not friendly. “Illegal Operation” is not friendly and may even scare a child into thinking the police are on their way. In case of an error, the system should gracefully handle it, inform the user what went wrong (on a high level that they can understand) in a non-threatening, non-patronizing way, and tell them what action to take next.

A friendly system guides its users and encourages them to use it. It neither talks down to users nor talks up to them. It treats the user as an equal. It behaves like a tool meant to accomplish something.

Closure

No one F is more important than the next. These are pillars of good design, not hard-and-fast rules. The goal is not to maximize each F independently but balance them consciously for the system’s audience and constraints. A website with too much flexibility can compromise its speed. A dashboard too familiar may squash the possibility for new features. Requiring overly friendly code could dominate pull request feedback. Every project will need to find its own balance of these principles. That balance is where we build the best systems for people. When those answers are balanced well, the system is not just working. It is doing its job.