Best practices are contextual

Submitted by Larry on 23 August 2017 - 6:51pm

Articles, blog posts, and Twitter debates around "best practices" abound. You can't swing a dead cat without bumping into some article espousing the benefits of designing and building software a certain way. (Side note: What kind of sick person are you that you're swinging a dead cat around? Stop that! You're desecrating the dead!)

What nearly all of these articles fail to convey is that "best practices" are contextually-dependent. Always. For one, they evolve over time; really, there is no such thing as a "best practice"; There are "leading practices", which is an inherently changing and growing definition as the technology and market changes and as we as an industry learn more and discover new and exciting ways to royally screw up.

More importantly, though, "leading practices" can be vastly different depending on the type of software being built, who it's for, and how it will be used. That's often left out of such discussions, which is a problem because without that context advice can often not just be inapplicable but actively counter-productive.

While every project is unique, I would define three broad categories in which a given leading practice could be valid (or not).

In-house applications

In-house applications are what they sound like: They're projects for which the developer is also the primary user, and changes to the code base can be made at any time. Usually these days they are hosted network or web applications but could be applications distributed within a corporate network.

Most hosted applications also fall into this category. Most "web startups" these days live here, too; they're building one big web application with a variety of public users, but only the in-house development team ever touches the code base, and they can touch the code base at any time. There is only one production instance of the code, too. It may be microservices or have redundant copies running but it's fundamentally only a single active in-use copy of the application.

Because this is what most trendy startups are doing, this is what most "best practices" think-pieces are written for. Trendy writers write for trendy startups like themselves and imagine that's the world. It's not.

Much of the advise given here is valid, though, up to a point. The love-it-or-hate-it YAGNI principle (You Ain't Gonna Need It) advocates doing the minimal work necessary to fulfill a feature without trying to guess what future extensions will be needed. Your odds of guessing wrong are high, leading to a lot of waste and over-engineering. That's true to a point, but it hinges on one very important fact: That the dev team can change any line of code at any time without difficulty. If that assumption doesn't hold, YAGNI fails.

Uncontrolled YAGNI is also a great way to build up technical debt. If the simplest way to add a feature is to hack in a one off, then that's what YANGI says to do since you don't know if a more robust solution would be needed. Sometimes that's correct; frequently, though, one-off solutions become 15-off solutions before you know it, and then untangling them is even more work than just building in a bit more flexibility from the get-go would have been. Always weigh the odds of a one-off becoming a many-off and the extra effort of giving yourself an escape hatch for the 2nd or 3rd one-off.

Remember: Like Pharaoh, your boss will assume that if you could do it once in 2 hours then you can do it every time in 2 hours, rather than it taking 20 hours by the time you're working on the 6th one-off. Would taking 4 hours the first time really have been such a bad idea?

Another aspect of in-house applications is that they frequently need to worry about only one runtime environment. The specific version of the operating system, application libraries, and so forth are fixed, under the developers' control, and only change in a controlled, deliberate fashion. (If not, your server team isn't doing their job.) That greatly simplifies the problem space for most issues. The exception is any browser-side component for a web application, for which there can still be an annoying amount of variation but still far less than there was just a few short years ago.

Hosted/in-house applications are also where continuous-delivery makes the most sense. In fact, I'd argue it's foolish to do anything else. Every time a new version is deployed is an opportunity for something to break, and the more changes there are the greater the chance one of them will break. Deploy early and often is also easy; there's only one instance to deploy to, and if it works there then that's all that matters.

Continuous-delivery, of course, requires a safety net in the form of robust, reliable, trustworthy tests. Others have spoken on the value of automated testing far better than I, so will simply say "amen".

Shipped applications

Shipped applications are identified by having a clear separation between developer and user, and each user having their own instance of the application. That includes a desktop application, a mobile application, or even a web application the user is expected to install on their own server. I would also include self-updating applications like Chrome or Firefox here as well.

Shipped applications have some things in common with in-house applications in that the code is entirely subject to the whim of the developers. As long as functionality isn't changed nearly anything in the code base can be refactored. However, it cannot be refactored at any time. Shipped applications by nature have discrete releases, and those cannot be too frequent because they need to be pushed out to dozens, thousands, or millions of users (depending on how successful it is). Users generally don't like their applications updating every day and changing subtly each time (although some mobile apps are training users into that, for better or worse; more on that another time).

That throws continuous-delivery out the window. YAGNI and similar may still apply, but only to a point. There's often no API compatibility to maintain, but there is user-interface compatibility and data file compatibility. While converting data files to a new version is possible it creates a host of potential issues for users, especially in a mixed-version environment. (Raise your hand if you remember Microsoft Word breaking file formats every version and then trying to email a file to someone with a different version.)

More pressingly, though, there's an exponential number of new environment considerations to think about. There are three major operating systems on the market (Windows, Mac, Linux). Even targeting just one, there's many versions installed in the wild, each with different hardware configurations, different versions of related software like drivers, system-level libraries and APIs, and user-selected system configuration. And every one of those has its own subtle set of hidden, undocumented bugs that exist only in versions you don't have available during development. Testing a website across multiple browsers is child's play by comparison.

As a result, defensive programming is vastly more necessary for shipped applications. Many things can and will go wrong that the developer never anticipated, and the application needs to be able to fail gracefully. That includes non-fatal error messages that provide some indication to the user of what's wrong and how they can fix it and what to tell the development team. Dumping a stack trace to a log file and exiting may work fine for a single-instance application but is a terrible user-experience for an installed application, and mostly useless for the development team in tracking down the issue.

Platforms and libraries

Now we get into an entirely different animal. The defining attribute of this type of software is that the end user is another developer, and specifically a developer disconnected from the developer of the library, platform, or service. Examples of this type of application include API-driven server software such as databases, search indexes, etc.; RESTful web services; single libraries (of which there are hundreds of thousands); application frameworks such as Symfony, Laravel, Ruby on Rails, and so on; development platforms such as DirectX, .NET, or PHP itself; extensible applications such as Firefox, WordPress, or Drupal; and many many others.

The leading practices and good recommendations for a platform or library are vastly different than for an in-house app. The biggest difference is that YAGNI is actively harmful more often than not. Just because you may not need it doesn't mean someone else won't. And since it's your code, not theirs, they won't be able to modify it on-the-fly to support it.

Upgrades are also a tricky problem, and very different when you're talking about a remote API vs a local library. For a remote API, you can't change your API without rolling it out to everyone at once. That's rather the point. That means a breaking change breaks everyone's actively running code at once. (This is bad.) API versioning is its own rich topic on which people more eloquent than I have ranted, so will leave that here. I will, however, note that XML handles evolving an API way better than JSON does. Think about that the next time you're whining about XML being so hard.

If instead we're talking about a locally installed library or framework, you have the opposite problem: You can't roll out changes to everyone at once; some people will use old versions of the library forever and you can't stop them. Others may want to use a new version but, if the API changes, would have to rewrite some or all of their code that leverages it. That means "meh, I can write the delete process later when I need it" is horribly short-sighted, because the odds of someone needing that operation are high, your ability to release it to them on-the-fly is low, and the odds of adding that operation maybe requiring an API change in order to keep the whole thing consistent are... completely unpredictable (which means assume they're high.)

And let's not even get into the potential mess that shared libraries create, where everything on the same computer has to be updated to the new version at the same time. Which basically never happens, so you're stuck with it forever.

The flip side, of course, is that even if YAGNI is harmful, so is blindly over-engineering and producing an uber-flexible API that is also unusable due to its complexity. The infamous and inflamatory Developer UI is a legitimate problem at the code level as much as at the user level. Getting that balance right is something that comes with experience, research, and humility. (Emphasis on the latter two.)

Remember: An API is a user interface for other programmers! It deserves just as much attention to user-experience as the pretty GUI does.

Continuous automated testing of an API is crucial to catch regressions. Continuous delivery less so. If it's a hosted REST API then it may make sense, or not. The trade-offs there are worth their own article. If it's a locally-installed library, or godforbid a shared library, Continuous delivery is completely off the table.

Dealing with multiple runtime environments may or may not be an issue, depending on the library. Sometimes abstracting runtime environments is the entire point of the library; other times it's well-insulated enough that you don't care.

Because there's a clear split between the developers building and the developers using the library, concepts like encapsulation, data hiding, API surface area, and so on are critically important. When the entire code base is built by the same team (for an internal or shipped application), just documenting what properties are available and trusting people to not do silly things is often fine. For a library or platform, thinking through what you'll allow the using-developer to touch, what will be actively hidden, and what you'll allow them to do but not actually support is a critically important question that simply doesn't exist in some other contexts.

That does mean that, yes, some programming techniques, paradigms, and even languages are better suited to one style of code than another, because the leading practices they support and encourage are different. (Not all languages/paradigms are created equal. Deal.)

Back to the debate

So where does that leave us in online debates? Well, still debating, because Internet and humans.

However, if there is any take away I would urge you to have it's that context matters. "Leading practices" really do exist, they really are valuable, and you really should be following them unless you have a well-informed reason not to. However, you also need to understand what context a given leading practice is intended for, both when listening to it and advocating it.

Accepting a common leading practice because it's leading practice is fine... if it's appropriate in your context. You have to judge the applicability of the context yourself.

Advocating for a common leading practice is fine... as long as you include the context for when it's a good practice, when it's just a good habit, and when it's probably not actually a good idea. To advocate for any particular practice without context is, I would argue, irresponsible.

And now back to your regularly scheduled Internet debate, already in progress.