The software engineering rule of 3
Here's a dumb extremely accurate rule I'm postulating* for software engineering projects: *you need at least 3 examples before you solve the right problem*.
This is what I've noticed:
- Don't factor out shared code between two classes. Wait until you have at least three.
- The two first attempts to solve a problem will fail because you misunderstood the problem. The third time it will work.
- Any attempt at being smart earlier will end up overfitting to coincidental patterns.
(Note that #1 and #2 are actually pretty different implications. But let's get back to that later.)
What's he talking about? Example plz
Let's say you're implementing a class that scrapes data from banks. This is an extremely dumbed down version, but should illustrate the point:
class ChaseScraper:
def __init__(self, username, password):
self._username = username
self._password = password
def scrape(self):
session = requests.Session()
sessions.get('https://chase.com/rest/login.aspx',
data={'username': self._username,
'password': self._password})
sessions.get('https://chase.com/rest/download_current_statement.aspx')
Now, you want to add a second class CitibankScraper
that implements the same interface, but changes a few implementation detail. In fact let's say the only changes are that Citibank has different URLs and that their form element have slightly different names. So we add a new scraper
class CitibankScraper:
def __init__(self, username, password):
self._username = username
self._password = password
def scrape(self):
session = requests.Session()
sessions.get('https://citibank.com/cgi-bin/login.pl',
data={'user': self._username,
'pass': self._password})
sessions.get('https://citibank.com/cgi-bin/download-stmt.pl')
At this point after many years of being taught that we need to keep it “DRY” (don't repeat yourself) we go ermahgerd, cerd derplication!!! and factor out everything into a base class. In this case it means inverting the control and let the base class take over the control flow:
class BaseScraper:
def __init__(self, username, password):
self._username = username
self._password = password
def scrape(self):
session = requests.Session()
sessions.get(self._LOGIN_URL,
data={self._USERNAME_FORM_KEY: self._username,
self._PASSWORD_FORM_KEY: self._password})
sessions.get(self._STATEMENT_URL)
class ChaseScraper(BaseScraper):
_LOGIN_URL = 'https://chase.com/rest/login.aspx'
_STATEMENT_URL = 'https://chase.com/rest/download_current_statement.aspx'
_USERNAME_FORM_KEY = 'username'
_PASSWORD_FORM_KEY = 'password'
class CitibankScraper(BaseScraper):
_LOGIN_URL = 'https://citibank.com/cgi-bin/login.pl'
_STATEMENT_URL = 'https://citibank.com/cgi-bin/download-stmt.pl'
_USERNAME_FORM_KEY = 'user'
_PASSWORD_FORM_KEY = 'pass'
This would let us remove a lot of lines of code. It's one of the most compact ways we can implement these two bank statement providers here. So what's wrong with this code? (Apart from the general antipattern of implementation inheritance).
The problem is we're overfitting massively to a pattern here! What do I mean with overfitting? We're seeing patterns that really don't generalize well.
To see this, let's say we add a third provider that is slightly different. Maybe it's one or more of the following:
- It requires 2-factor authentication
- Credentials are sent using JSON
- Login is a POST rather than a GET
- It requires visiting multiple pages in a row
- The statement url is generated dynamically based on the current date
… or whatever, there is another 1000 ways this could break down. I hope you see the problem here. We thought we had a pattern after the first two scrapers! It turns out there really wasn't that much that generalized to the third provider (and more generally, to the nth). In other words, we overfit.
What does Erik mean by overfitting?
So overfitting is a term for when see patterns in data and those patterns don't generalize. When coding we're often hyper-vigilant about optimizing for code deduplication, we detect incidental patterns that may not be representative of the full breadth of pattern that we would see if we knew all the different applications. So after implementing two bank scrapers we see a pattern that we think applies more generally, but really it doesn't.
Note that code duplication isn't always such a bad thing. Engineers often focus way too much on reducing duplicated code. But care has to be taken to distinguish between code duplication that's incidental versus code duplication that's systemic.
Thus, let me introduce the first rule of 3. Don't worry so much about code duplication if you only have two classes or two functions or whatever. When you see a pattern in three different places, it's worth thinking about how to factor it out.
Rule of 3 as applied to architecture
The same reasoning applies to system design but with a very different conclusion. When you build a new system from scratch, and you have no idea about how it's eventually going to be used, don't get too attached to assumptions. The constraints you think you really need for the 1st and the 2nd implementation seem absolutely crucial, but you're going to realize that you got it all wrong and the 3rd implementation is really the one where most of the things are right. Ok, this is obviously all extreme blanket statements here. Don't use my advice for brain surgery or nuclear fission.
As an example, Luigi was the third attempt at solving the problem. The first two attempts solved the wrong problem or optimized for the wrong thing. For instance the first iteration relied on specifying the dependency graph in XML. But this turned out to be super annoying for the reason that you really want the ability to build the dependency graph programmatically. Conversely a bunch of things in the first two attempts that seemed really useful, like decoupling outputs from tasks, ended up adding far more complexity only to support some obscure edge cases.
What would have seem like obscure niche cases in the first iteration because very central in the final iteration, and vice versa.
I was reminded of this when we built an email ingestion system at Better. The first attempt failed because we built it in a poor way (basically shoehorning it into a CRUD request). The second one had a solid microservice design but failed for usability reasons (we built a product that no one really asked for). We're halfway through the third attempt and I'm having a good feeling about it.
These stories illustrate the second rule of 3 – you're not going to get the system design right until the third time you build it.
More importantly, if you are building the first implementation of some hairy unknown problem, don't assume you're going to nail it. Take shortcuts. Hack around nasty problems. You're probably not going to keep this system anyway – at some point it's going to break. And then the second version breaks most of the time. The third though – that's when you perfect it.
Notes
- Hacker news discussion
- Reddit discussion on /r/programming/
- People on the internet pointed out that this rule already exists [1] [2] [3]. I wasn't aware of any of those, but it's highly likely I've read it at some point a long time ago. Not trying to misappropriate ideas that have been around for a long time!