How To Fight Premature Abstraction Early In The Code
I sometimes have to clean up overly complex code.
Only to rewrite it myself, in an even worse way. (It’s harder to follow your own principles than to teach other. So I write this to remind myself, and keep this as a reference for the future.)
The inconvenient truth is this:
Your abstraction won’t matter in the future
My name is Till Carlos and I document how my team builds products. And today we are talking about what can kill progress in software projects, early on.
This is inspired by
- Interviews I did at ruby conferences in 2024
- This awesome blog post
And then one of my developers suggested of implemented a new layer, and my detector went off right away:
Let’s sort this out.
The Abstraction Trap
Eileen M. Uchitelle, eileencodes.com, on “What makes projects fail”, in my blog post
You’ve probably been there. You’re coding along, and suddenly you see a pattern. “Aha!” you think, “I can abstract this!” Before you know it, you’re knee-deep in creating a beautiful, reusable piece of code that will solve all your problems… or so you think.
But here’s the kicker: most of the time, that abstraction you’re so proud of? It won’t be used more than once or twice. And worse, it might make your codebase harder to understand for the next poor soul who has to maintain it (which, let’s face it, might be future you).
Do too much complexity out of the gate, then you end up with a failed project.
I often see this with Services. People start with the best intentions
module Service
class GenerateUsersCsv < ::Service::Base
EXPORT_LIMIT = 5
def self.call(start_date, end_date)
headers = [
'Name', 'E-Mail', 'Registered Date'
]
start_date = start_date.to_datetime.beginning_of_day
end_date = end_date.to_datetime.end_of_day
rows = User.where(created_at: start_date..end_date)
.limit(EXPORT_LIMIT).map { |user| create_row(user) }
AppServices.generate_csv(headers, rows)
end
...
end
And then they want to keep consistent. Do you want to guess generate_csv
?
It’s this:
require 'csv'
module Service
class GenerateCsv < ::Service::Base
def self.call(headers, data_rows)
CSV.generate(headers: true) do |csv|
csv << headers
data_rows.each { |row| csv << row }
end
end
end
end
And on top of this, we now need to route the services:
class AppServices
def self.generate_csv(*, **)
::Service::GenerateCsv.call(*, **)
end
def self.generate_users_csv(*, **)
::Service::GenerateUsersCsv.call(*, **)
end
end
Someone just compacted 4 lines of code into one… at he expense of:
- A new class that needs tests
- 13 new lines of code
- A new route in AppServices
- Explaining to other devs they should now all use this new service.
It looks great when creating it. The world of “all services, everything” looks glossy on the outside.
But later on, it can convert to the dark side fast:
- The Services router gets complex
- Authorization gets more difficult (where to put the authorization calls?)
- More tests are needed, that means more files.
Creating a service like this might be the best idea. But it should be done later, when the simplest approach does not work.
What’s the simplest approach?
This is what I learned from devs who are much better than I.
How to get out of it
Several things were said in a conversation, and I’ll document them here. The first one sounds controversial, especially to Rails devs. But it makes sense if you think about it.
Duplicate code
Yes, violate DRY. Build technical debt. Until you know better.
Then count how often your code gets duplicated. It’ll be probably stop after 2, maximum three times.
Once it goes over 4, then you can think ouf a beautiful solution.
Go the standard way
For us, it’s the Rails Way. We follow the MVC approach.
- Views display the data from models
- Controller initialize and modify data (also: Auth*)
- Models persist and load data.
What does this mean for our service? We can copy the principle from 37signals, the company that built Rails. Not everything they touch is gold, but we get a good idea of how to keep things simple.
app/models/first_run.rb
class FirstRun
ACCOUNT_NAME = "Writebook"
def self.create!(user_params)
account = Account.create!(name: ACCOUNT_NAME)
User.create!(user_params.merge(role: :administrator)).tap do |user|
DemoContent.create_manual(user)
end
end
end
This is a model. No, it doesn’t persist data, but it’s very easy to find in the app/models
folder. The controller working on it is named similarly:
app/controllers/first_run_controller.rb
def create
user = FirstRun.create!(user_params)
start_new_session_for user
redirect_to root_url
end
We should ask ourselves: do we really need a more complicated approach right now? Or can we go with the simplest possible??
Only go abstract, if
Just some pointers if we are still not sure:
- You have seen something proven before (Yes, I’d count the services to that)
- Are there real lines of code saved? Is real duplication avoided?
- Does this new abstraction help the understanding of the code?
- Will other developers be able to pick this up fast?
And lastly:
What would happen if nobody ever continues with your journey of abstraction? Would it still make sense there?
If you disagree with this: send me a nice message on twitter.