In the software world, there’s a pervasive obsession with premature refactoring and the chase for fake reusability. Developers—especially those starting out—are often taught that "reusability" is the holy grail. But the pursuit of reusability at all costs often results in over-engineered solutions that are too generic, too rigid, and too far removed from the specific needs of the project at hand. In fact, it can lead to what we often call the "abstraction hell"—a scenario where nothing really works unless you fully understand how and why every part of the system was abstracted to fit a generic interface.
We suggest a paradigm shift: Instead of obsessing over reusability, let's focus on adaptability, extensibility, and overridability.
In this context, we’re moving away from trying to predict the future needs of our codebase (like a fortune teller predicting the future) and instead focusing on creating a solid, flexible foundation for today that still has room to grow and evolve as the future unfolds.
The Premature Refactoring Dilemma: Fake Reusability
The problem with premature refactoring is that it comes from the belief that everything you write should be reusable. This might seem like a noble goal. However, reusability often leads to unnecessary complexity and unnecessary abstractions. Take, for example, the notion of creating a universal API adapter that works for all your models. The ideal is that this adapter can handle any API endpoint, any data format, and any network condition. But in reality, this means you're building a framework for an uncertain future, not solving today’s problems effectively.
Example:
Let’s take our earlier BaseAdapter
and APIAdapter
classes:
export class BaseAdapter {
constructor(modelClass) {
this.modelClass = modelClass;
}
async get(id) {
throw new Error("Method 'get' must be implemented.");
}
async *all() {
throw new Error("Method 'all' must be implemented.");
}
async query(params = {}) {
throw new Error("Method 'query' must be implemented.");
}
async create(payload) {
throw new Error("Method 'create' must be implemented.");
}
async update(payload) {
throw new Error("Method 'update' must be implemented.");
}
async delete(id) {
throw new Error("Method 'delete' must be implemented.");
}
}
In the above code, the BaseAdapter
defines every possible method, leaving us to implement them in specific subclasses (like APIAdapter
, LocalStorageAdapter
, etc.). This is a template for various adapters. It sounds good in theory, right? One day, if we need to connect to a new service or integrate with a new storage solution, we can just create another subclass.
But let’s get real: Will it really be reusable? Or will it just become a big ball of complexity, making your system harder to maintain, understand, and extend? Are you really building something that can be reused in the real world, or are you just guessing about the future?
The Shift: From Reusability to Adaptability, Extensibility, and Overridability
Instead of pursuing premature reusability, we propose focusing on adaptability and extensibility. What does that mean?
- Adaptability: Create a foundation that can change or extend easily without rewriting large portions of code.
- Extensibility: Leave room for new functionality without having to refactor your entire architecture.
- Overridability: Allow your code to be easily extended or overridden by others (or yourself in the future) without risking breaking everything.
This isn’t about creating the perfectly reusable code that works for every edge case today. Instead, we focus on building a solid base that you can build on, add to, and modify over time. The key is flexibility, not premature optimization.
The Old "Interface" Paradigm: Predicting the Future
In the old days of Java (and many other statically typed languages), the focus was often on creating interfaces and making your code “future-proof.” The idea was to anticipate every scenario in advance and design around it.
However, this approach can often result in over-engineering: designing for things that might never happen or building abstract frameworks around problems that are yet to surface. You’re effectively writing code that’s supposed to be “universal” without understanding the concrete needs of the system you’re working on.
In Java, interfaces were used to define contracts. But what if we changed this thinking from “defining contracts” to simply setting expectations for the present? A promise that’s clear and reliable for the immediate context, without assuming what will happen in the future.
A New Kind of Promise: A Promise to Our Future Selves
In our new approach, we don’t make promises about the future of the application like some mystical fortune teller. Instead, we set clear, reliable promises for today, and make sure that these promises can be extended and adapted easily when the need arises.
Think of it like this: we’re not predicting what the world will look like in 5 years; we’re ensuring that the code we write today can evolve and adapt as the world changes. It's like laying down a solid foundation for a building, making sure it's sturdy enough to withstand whatever changes come.
The “promise” we make is a commitment to adaptability and extensibility. The goal is not to predict the future, but to create the tools that will allow future developers (or your future self) to easily add, modify, or extend functionality as needed.
Real-World Example: Extending and Overriding Adapters
Let’s revisit our example with the BaseAdapter
and APIAdapter
. Instead of creating super generic methods that attempt to handle all situations, we’ll focus on making the code adaptable and easily extendable.
Here's a quick re-architecture of the APIAdapter
:
export class APIAdapter extends BaseAdapter {
static baseURL;
static headers;
static endpoint;
async *all(params = {}) {
// Custom logic, but easily extensible if needed
const url = `${this.baseURL}/${this.endpoint}`;
const response = await API.get(url, { params, headers: this.headers });
return response.data;
}
async query(params = {}) {
// Simplified for illustration
const url = `${this.baseURL}/${this.endpoint}/search`;
const response = await API.get(url, { params });
return response.data;
}
// Easily extendable for specific cases
async customRequest(method, endpoint, params = {}) {
const url = `${this.baseURL}/${endpoint}`;
const response = await API[method](url, { params });
return response.data;
}
}
Now, instead of creating a whole new BaseAdapter
for every new type of adapter, we’ve created a foundation that can be easily extended and adapted for future needs.
Example of extending for a new API endpoint:
class OrderAdapter extends APIAdapter {
static baseURL = 'https://api.example.com';
static endpoint = 'orders';
}
class UserAdapter extends APIAdapter {
static baseURL = 'https://api.example.com';
static endpoint = 'users';
}
In this scenario, if you need to add specific behavior for one API endpoint (e.g., custom error handling for orders
), you can override or extend the APIAdapter
to fit your needs without refactoring the entire system.
Conclusion: The Promise to Our Future Selves
In this new paradigm, we’re not trying to predict every future need or problem. Instead, we focus on building a strong, flexible foundation that adapts as requirements change and new challenges arise. We don’t prematurely abstract or over-engineer solutions based on hypothetical problems. Instead, we create tools that can evolve and be easily adapted as new needs come up.
The key is not future-proofing like a fortune teller, but creating a foundation that will reliably stand the test of time, even if the world changes. This is a promise you can make to your future self: the code is solid, adaptable, and ready to be extended as new requirements come into play.
Top comments (0)