Locality of Behaviour
This month’s Stack Report is the first of a two-parter. I’m also making it freely available to all, since it touches on topics I talked about in my recent DjangoCon Europe talk in Vigo, and I want to expand on those points.
In recent weeks there’s been a flurry of discussion about where to keep your business logic when using Django: maybe in your view, in so-called fat models, or in some variation of a service layer.
This reminds me that I have a long-standing TODO to explain my take on such things. So next month I’ll be discussing service layers in Django. First, though, I need to discuss Locality of Behaviour, since what I have to say on service layers depends for much of its context on how considerations of Locality of Behaviour come into play.
That’s the outline. On with it!
§
Last month we talked about Shipping Software on Time and on Budget.
As was said, a lot of that is about doing sufficient discovery to make sure that we’re in a position to deliver on a given time frame with a reasonable level of confidence.
There are other things we can do to help, though. If we can go faster, that has obvious benefits. If we can reduce rework, we’re not burning effort on the same thing multiple times. And if we can know when we’re done — if we can say yes, this is good enough – we can avoid over-engineering parts of our code, and spend that time where it’s needed.
One tool, or heuristic, that I think helps with all of these things – going faster, reducing rework, and knowing when you’re done – is the idea of Locality of Behaviour, and that’s what I want to talk about today.
Locality of Behaviour
Locality of Behaviour is a way of thinking about and assessing code.
Some code has Locality of Behaviour when everything you need to understand that code is there in the one place.
The contrast is when you need to go and look somewhere else to see what the behaviour is going to be.
The idea is that, other things being equal, greater locality of behaviour makes your code easier to reason about, easier to maintain, and easier to iterate on.
You’ll be familiar with this.
- Tailwind CSS is a utility-first CSS framework that’s very popular. It gives you utility classes that you embed in your HTML to control styling, rather than having to keep that code in a separate CSS file.
- Alpine.js is a rugged little jQuery-like JavaScript framework, that lets you define your component behaviours (again) in your HTML, rather than in a separate JavaScript file.
- And (of course) HTMX adds a small set of HTML attributes that expand the native powers of HTML, all again without leaving your HTML.
The canonical discussion of Locality of Behaviour is an essay on the HTMX website that I highly recommend.
When I initially found the essay it was a moment of joy. I’ve been talking about this kind of concept with clients for years, but I never had a term for it.
In general, a Django project will have plenty of files. You often end up with (for example) a form, that’s perhaps only three or four lines of code. You’re busy working on the view, but then need to go and track down the related form off in its own forms.py
file to see those three or four lines. (You’ve got a three line manager off in a managers.py
file, and so on – the examples are many.)
I’d argue that we should put the form, say, next to the view that uses it, to make everything easier to reason about. Over the years I’ve got, frankly, tired of having four, five, or even more files open, just to be working on a single piece of code.
“But that’s unprofessional”, the complaint would go. “What about separation of concerns?”
Yes, yes, yes. I get that. I got that. But I didn’t have the right sized hammer to argue against it. Locality of Behaviour is that hammer – it’s precisely the right concept to let us capture what’s at stake here. (I’m a big fan, as you can tell.)
On top of the tools above – Tailwind, Alpine.js, HTMX – both in the name of better Locality of Behaviour – over the last year or so, I suggested Neapolitan, which is my take on quick CRUD views for Django, and django-template-partials
, which adds reusable named template fragments to the Django Template Language.
I’ve proposed that — particularly with new code, while you’re still working it out, while it’s still in flux — a focus on Locality of Behaviour can help you go faster.
Starting point, not a Destination
But you need to put up some disclaimers at this point.
Folks tend to have a bit of a bad reaction to Locality of Behaviour.
It’s like you proposed throwing out everything we ever learnt about good engineering.
Haven’t you heard of DRY?
Haven’t you heard about Separation of Concerns?
Even though you said it was Locality of Behaviour first, folks seem to think you said Locality of Behaviour only.
Tailwind in particular seems to draw people’s ire. Hardly a week goes by without some CSS luminary or other publishing a hit-piece. Now, let’s be absolutely clear: this is gatekeeping of the worst order. I thought we’d learnt long ago that throwing shade on other peoples’ tech choices was a no-no — like all those folks who tell you, you can’t use an ORM. Instead of attacking people’s choices, we’re better served asking how come it is they’re not sold on our one true way. (As if folks had never worked on a multi-team project with an effectively append-only stylesheet… 🤷♀️)
Nonetheless, just to reemphasise: Locality of Behaviour is a starting point, not a destination.
If I’ve got a button styled with Tailwind, I’m not going to mindlessly copy and paste those classes every time I need a button.
I might stick the HTML in a partial or a template tag, but more likely I’m just going to pull it into my CSS file as a new button utility — as anyone would obviously do.
Tailwind has some seemingly crazy super-powered classes. For example:
[&>a]:text-black
Roughly: Get all child a
elements, and set the text colour to black. (You might imagine a sibling here with a text-white
variant of the same.)
"Surely, I’m not going to use this?” — Why wouldn’t you style the child element directly? Why wouldn’t I put that in CSS? Well, if you control the HTML, of course you likely would. But just maybe, if that HTML is coming out of a CMS, and the rest of your page styles are inlined using Tailwind, it might be less disruptive to your code to use one of these power selectors, than to ship just that one bit of styling logic out to a separate file on its own. It’s going to depend, but it’s not a priori unreasonable to have it on the table as an option.
Alpine.js is awesome. A one-line handler in your HTML beautiful. Two-lines, no problem. At any point, one more line is not an issue. But at some point — probably quite soon — you’re going to pull your JavaScript out into a separate file, or into an Alpine plugin, so you can use it with just an attribute declaration on your HTML.
You'll get linting, formatting, testing, all of it.
Again, you will use your judgement as to when to sacrifice a bit of Locality of Behaviour as your code scales to gain in maintainability.
If you’re using Neapolitan’s CRUDView
you might see that Oh, this logic would be better as a separate view. And so on.
A focus on Locality of Behaviour doesn’t mean that we suddenly abandon all the engineering principles that we learnt since we were juniors. It’s a tool, like any other. Use it appropriately.
I’ll try not to wink with the wrong eye as I say that, of course you shouldn’t put 3000 lines of code in your urls.py
. 😉
Hold the line!
But I will say that holding the line on Locality of Behaviour can really pay dividends.
DRY. Separation of Concerns. Sure, sure, sure.
But for every one of those there’s a Write everything twice, a Three Times, Refactor.
As I was writing this talk, Luke Plant published a nice post comparing these things to proverbs — they’re great when they apply, but there’s always a contradictory one if you need it. Again, we need to be sensitive with these things.
A principle I learnt as a junior is called Protected Variations. The idea is that you identify the areas of your code that are subject to change. You build a stable interface around them, so that even as they evolve, they don’t infect everywhere else.
Well, new code — when you’re still working it out, when it’s still in flux, when you don’t really know what it’s going to look like — it is the perfect case of code that’s likely to change.
By focusing on Locality of Behaviour — by keeping everything together — you make sure that any changes don’t escape out of that file, or out of that module.
It appears that you’re repeating yourself — violating DRY — but if you extract the code somewhere and then reuse it, when you realise it needs to change you’ve got multiple places to fix. You violated protected variations.
On the other hand, if you can just defer extracting that bit of code slightly — focusing on locality of behaviour — you allow the code to stabilise.
This means less churn – less rework – less time spent going over the same thing again and again.
But it also buys you more time for the deeper patterns to emerge.
Quickly refactoring to DRY code is all very well and good. But it can be a premature optimisation. It can lead to your code getting stuck in local optima.
Again, as I was writing this, as post appeared on the Google Testing Blog: Don't DRY Your Code Prematurely. It’s singing from my song sheet:
Applying DRY principles too rigidly leads to premature abstractions that make future changes more complex than necessary. … Consider carefully if code is truly redundant or just superficially similar. … When designing abstractions, do not prematurely couple behaviours that may evolve separately in the longer term.
Buying the space for the deeper patterns to emerge is, for me, one of the greatest payoffs of thinking about locality of behaviour.
I think it’s fascinating to think about how far we can or should push locality of behaviour when building our apps.
Again, to clarify, I think that’s a discussion. I think it’s case-by-case, app-by-app, team-by-team — but from where I’m sat it’s super exciting.
Stop when you’re done
Finally, then – and this leads into what I’m going to discuss next month when we think about service layers in Django – by focusing on Locality of Behaviour, and by holding the line there, in deferring slightly the move to a higher abstraction, you give yourself a natural stopping point when your code is good enough, without having spent time that turned out to be unnecessary building something more sophisticated.
Absolutely, as per above, we’re not abandoning sound engineering practices whilst focusing on Locality of Behaviour. But those things we deferred have a cost: separation of concerns, say, puts everything in its own (correct) file, but that carries the cognitive burden of having to hold more in mind, which we began with. (And so on.)
As we’re working we see an upcoming refactoring on the horizon. Yeah, absolutely, we’re going to need that when we get a bit further along.
But maybe, and maybe quite often, we never in fact get to that further point. Maybe we’re feature complete before that. Maybe our time, and our budget, runs out, or is better spent elsewhere. Maybe we just saved ourselves a bucket load of wasted work. (Maybe Locality of Behaviour is YAGNI’s best friend.)
Now that’s schematic. A full discussion will have to wait, and will benefit from concrete examples. But it shows the path. Deciding what counts as good enough isn’t easy. Nonetheless, it’s vital if we’re building software under real-world constraints. (Real world?: Where someone’s — likely someone else’s — money is involved.) I’m a fan of thinking about Locality of Behaviour for all number of reasons, but that it gives us something actionable to say here is, I might argue over a beer, one of my favourites.