Clean code is about clarity and maintainability, not about building a cathedral for every feature. I’ve seen too many projects, including my own early ones, become unmaintainable not because the code was messy, but because it was overly clever and abstract. The goal is to write code that your future self, or another developer, can understand and modify in six months without a week of archaeology. Let's talk about how to write clean code without over-engineering it into an academic exercise.
The core tension is between flexibility and simplicity. Over-engineering happens when we solve problems we don’t have, adding layers of abstraction "just in case." Clean code, done right, solves the problems we do have in the most straightforward way possible. It’s a practical discipline.
Start by writing the dumbest code that works
Your first implementation should be embarrassingly obvious. If you need to filter a list of users, write a simple for loop or a .filter() with the condition inline. Don't immediately reach for a custom FilterStrategy factory with a registry pattern. I enforce this by writing the complete, working feature with direct, procedural code first. Only then do I look at it and ask: what is genuinely hard to understand or change?
// First pass: direct and clear.
function getActiveUsers(users: User[]): User[] {
return users.filter(user => user.isActive && !user.isDeleted);
}
This is clean. It might not be "extensible," but you don't need extensibility until you have a second, different way to filter users. Premature abstraction is the root of over-engineering.
Do you really need a design pattern?
Design patterns are a vocabulary for solutions to common problems. They are not a checklist for your codebase. The question is never "Where can I use the Factory pattern?" but "Do I have a problem that the Factory pattern solves?"
I apply a simple rule: I only introduce a named pattern when I find myself explaining the same architectural concept verbally to another developer. If I’m saying, "Well, this class creates the object, but the type depends on this config..." then maybe a Factory is the right shorthand. But if the creation logic is two lines, a static function called createUser() is cleaner than a full UserFactory class hierarchy.
Refactor based on concrete change, not speculation
This is the most practical rule I follow. I never refactor code because "we might need to support another database someday." I refactor when I am actively implementing a second database driver, or a third user role, or a new report format. The pain of the duplication or the awkward if statements becomes real, and the path to a cleaner abstraction is illuminated by the concrete requirements.
Over-engineering is speculative refactoring. Clean code emerges from adapting to real change. Wait for the second example of a concept before you abstract it. The first is just an example; the second reveals the pattern.
How to write simple tests that guard against complexity
Tests can either be a bulwark against over-engineering or a driver of it. Complex, mock-heavy tests often signal that your code’s dependencies are too tangled. I aim for tests that are so simple they feel like they’re testing the obvious.
If a test requires setting up five mocks and a spy, the unit is probably not a unit—it’s a tightly coupled integration. This pushes me to simplify the design. I often use a straightforward integration test against a real, lightweight test database or a mocked API client. It tests the actual behavior a user cares about and keeps the underlying code pragmatic.
// A simple integration test that guides design.
test('user subscription renews correctly', async () => {
const testUser = await createTestUser({ plan: 'trial' });
await renewalService.runRenewalForUser(testUser.id);
const updatedUser = await getUserFromDb(testUser.id);
expect(updatedUser.plan).toBe('standard');
// Tests behavior, not internal calls.
});
When should you abstract shared logic?
The rule of three is a good starting point: abstract when you see the same logic copied for the third time. But I add a nuance: abstract only if the copies are identical or nearly so. If the three cases are similar but have subtle differences, a parameterized function is better than an inheritance tree.
Create the abstraction at the point of the third copy, not in anticipation of it. And write the abstraction at the highest level of commonality. Often, a well-named function is sufficient; you don’t need a new service class.
// After seeing this check in three places...
if (user.isActive && user.lastLogin > cutoffDate) { ... }
// Extract a clear function.
function isRetainedUser(user: User, cutoffDate: Date): boolean {
return user.isActive && user.lastLogin > cutoffDate;
}
// No UserRetentionAnalyzer interface needed.
Write code for the feature you’re building today, and refactor mercilessly when you build the second one that touches the same concept.