Dialects in Code: Part 2

7 minute read

We previously looked at dialects, collections of programming practices that change how you can use the same programming language to express your program differently. In this article, we’ll look at how the different practices within a dialect can interact with each other.

Everything Flows

Short of a major innovation in tooling or methodology, dialects rarely change all at once. Instead, changes manifest as a shift in which practices we use. We introduce a new practice (make more objects immutable) or we stop using one (functions may only have a single return point) or we refine something we’re already doing (let’s migrate our property docblocks to property typehints).

These types of changes are inevitable and they help keep a codebase healthy. Even if you don’t deliberately change your practices, you’ll see changes over time by staying up to date with your dependencies or through the natural churn of contributors to the codebase.

Sometimes, though, a change proves more difficult than expected. You introduce something that looks great but maybe it requires more boilerplate code or you can’t get it working with your serialization library or any of those wonderful things that can make programming feel like death by papercuts.

In these cases, it might feel like the practice is deficient or not suited for your use case. And that might be true. But it might also be the practice works best in combination with other practices.

Supporting Practices

Let’s look at adding unit tests to a legacy application. It’s common to see code like this:

class SomeService
{
   public function isPumpkinSpiceLatteSeason(): bool
   {
       $now = new DateTime(); // automatically set to system time
 
       $startOfSeason = new DateTime('August 25, 2020');
       $endOfSeason = new DateTime('December 1, 2020');
 
       return $now >= $startOfSeason && $now < $endOfSeason;
   }
}

This method looks very testable: it has a clear scope and a finite number of outcomes. The method would also benefit from tests: boundary checks are so easy to make a mistake on.

Unfortunately, writing tests won’t be easy because the value of $now is derived from the current system time. This is a classic case for Dependency Injection: if we pass a Clock into the constructor of SomeService, we can mock it in the tests but use a real one in the running application.

class SomeService
{
   private Clock $clock;
 
   public function __construct(Clock $clock)
   {
       $this->clock = $clock;
   }
 
   public function isPumpkinSpiceLatteSeason(): bool
   {
       $now = $this->clock->now();
 
       $startOfSeason = new DateTime('August 25, 2020');
       $endOfSeason = new DateTime('December 1, 2020');
 
       return $now >= $startOfSeason && $now < $endOfSeason;
   }
}

It’s a few more lines of code but well worth it. SomeService has gone from “this is going to be really complicated to test” to “this can be covered with common testing practices.”

Now, it would’ve been painful but we could’ve theoretically tested this without using dependency injection (probably by messing with the system clock, yikes!). Conversely, Dependency Injection can still benefit us when we’re not writing tests (e.g., using it to implement a strategy pattern). Since these two practices can exist separately, we can see they’re two separate practices and not two aspects of one practice, but there is a clear relationship between the two: Code with Dependency Injection is easier to test.

When one practice enables another to be easier or more feasible, I call it a Supporting Practice.

Any practice can be a supporting practice for another practice. Further, any practice can have lots of supporting practices. Unit Testing, for example, benefits from Dependency Injection but also not using global state, reducing mutability, etc. Not all of these are required, but they all contribute to making unit testing easier.

In turn, Unit Testing can be a supporting practice itself, improving your experiences with Continuous Integration, Continuous Deployment, and Just Keeping Stuff Actually Working.

Why Do Supporting Practices Matter?

Plenty of ink (and blood) has been spilled on the benefits of Dependency Injection and Unit Testing, why try to name this relationship?

First, just acknowledging the existence of supporting practices can reframe how we approach new practices. When a change to our dialect isn’t working, this gives us an extra dimension to step back and debug. Maybe we ARE doing it right, but there’s something we’re missing? Is there something different between our environment and the person we learned it from? Working through these questions can be enlightening.

Second, when we try to teach new practices, it helps us remember we need to include which supporting practices we’re counting on. This can be difficult considering how many practices we work with and that the connection isn’t always clear. Modern software engineering is often just chains and chains of supporting practices.

Domain events, for example, are immutable events that your entities generate. Sounds straightforward, until you try to implement this and realize it’s almost impossible without dropping auto-increment ids and switching over to app-generated IDs, e.g. UUIDs.

Domain events and UUIDs might not seem connected at first. If you were already using UUIDs, you might have never even considered the issue, but recognizing your supporting practices early on will make it much easier for folks to add them to their dialect.

The Flip Side

If we’re going to highlight practices that help other practices, is it possible that some practices hurt other practices?

Short answer: yes, though hurt is relative. Based on our previous example, we could say that auto-increment ids are a practice that might require more work to integrate with your domain events. It’s not that auto-increment ids are inherently evil, it just happens to negatively impact another practice you’re currently using.

For this reason, I prefer to call them Complicating Practices, because they’re not “bad”, they just raise complications for other practices.

Depending on your dialect, the complications may not even be an issue. The procedural dialect I write my one-off shell scripts is very concrete and easy to scan but that makes dependency injection difficult. However, I don’t do much abstraction or unit testing in my shell scripts, so it’s a tradeoff I’m okay with. However, the enterprise-y dialect I write larger applications in cares very much about testing and Dependency Injection so it’s not a good tradeoff there.

Still, aren’t there inherently evil practices? Well, some practices complicate so many other practices that they’re rarely worth using. In our unit testing example, we could use a Singleton for a database complication. This is a Complicating Practice for unit tests but also complicates transactions, multiple databases, locking, and, well, lots of other things.

It’s not impossible to find a place where Singletons make sense. Maybe they’re a decent transitional step in an ancient legacy app. It’s just unlikely they have good ROI in the trade-off economy when you have any other choice.

The Ergonomics of Practices

When we start considering the push and pull dynamic of Supporting and Complex Practices, we begin to see a dialect isn’t random habits cobbled together. It’s a constantly evolving balance of trade-offs as new practices are introduced, merge with others, get borrowed and mutated in other dialects, and even return to their source as something different.

Programmers will optimize for a set of practices that can co-exist with minimal complications. For a new practice to be successfully adopted, it doesn’t just need to stand well on its own, it needs to do at least one of the following:

  • Mesh well with existing practices
  • Offer replacements for the practices it complicates
  • Contain such an overwhelming advantage that it’s worth complicating other practices
  • Be shoved down the dialect’s throat through political or social clout

After enough iterations, dialects can begin to form semi-stable bundles of practices. Practices can entangle with each other and mutation may slow down. Some practices will be added or removed purely to support other practices or avoid complications.

Old School

Occasionally, you’ll have vestigial practices remain that no longer influence anything. As an example, it was once common to enforce that every function only had a single return statement. Apocryphally, this was to help clarify flow control in an era of gotos or helped ensure you deallocated your memory giving you only one spot to do it.

As supporting practices, the “Single Return Law” might’ve been useful when more programs were written in FORTRAN or C but that isn’t the case anymore. Thus, dialects have shifted away from this practice, to avoid complicating the use of Guard Clauses where there’s a number of early if/return statements at the beginning of functions.

Leftover practices like these can haunt us for a long, long time. Looking at what these practices support or complicate can help us recognize them earlier.

True Neutral

Finally, remember that sometimes a practice is just a practice and doesn’t support or complicate anything. Many stylistic practices are like this. Do you like a space after your negation operator like this?

if (! $pumpkinSpiceLatteSeason) {
   //...
}

If so, cool. If not, also cool. It may function as an identification signal in your dialect but I don’t think it impacts anything else.

Summary

In this article, we covered the ideas of Supporting and Complicating practices. By trying to recognize them and taking care to communicate them along with a change in practices, we can help improve the likelihood of our change being adopted in a dialect.

We also discussed how the push and pull between practices helps shape dialects over time, and how it can leave some leftover practices that don’t offer much.

In the next installment of this series, we’ll start looking at how dialects impact the expressiveness of our code.

Updated: