Skip to content

When to Abstract Code in JavaScript: The 3 Rules to Avoid Overengineering

The Pain of Overengineering

I spent three days last week building a beautiful abstraction layer for user authentication. I created interfaces, base classes, factory patterns, and dependency injection. Then I realized I only needed to validate email addresses in two places.

My “brilliant” abstraction added 200 lines of code to handle a 10-line problem. When the product manager changed the authentication requirement, I had to refactor my entire beautiful system. That’s when I learned: elegant code that breaks is worse than simple duplicated code.

Here’s what I learned about when to abstract code the hard way.

The 3 Rules for JavaScript Abstraction

After shipping code to production for 8 years, I’ve distilled my experience into three practical rules:

  1. The 3 Places Rule: If code exists in 3+ places, extract it
  2. The Deletable Module Test: Can you delete the abstraction without breaking other parts?
  3. One Level Deep: Keep abstractions shallow, never nested utilities

Rule 1: The 3 Places Rule

What it means: If you write the same code three times, it’s time to abstract.

I tried to follow this rule too early. I abstracted after seeing similar code twice. That led to overengineering when the patterns diverged. The third occurrence is your validation that the abstraction is actually needed.

When I apply this rule:

  • Utility functions repeated across React components
  • Similar API calls in multiple modules
  • Duplicate validation logic
  • Repeated form field patterns

My mistake - Abstracting too early:

// After seeing this pattern TWICE
function UserProfile() {
const [user, setUser] = useState(null)
useEffect(() => {
fetch('/api/user/123')
.then(res => res.json())
.then(setUser)
}, [])
}
// I immediately created:
export const userApi = {
fetchUser: (id) => fetch(`/api/user/${id}`).then(res => res.json()),
fetchStats: (id) => fetch(`/api/user/${id}/stats`).then(res => res.json()),
// ... 5 more functions I thought I'd need
}

What happened: The next component needed different data fields. My abstraction was too rigid.

The correct approach:

// After seeing the SAME pattern THREE times
function UserProfile() {
const [user, setUser] = useState(null)
useEffect(() => {
fetch('/api/user/123')
.then(res => res.json())
.then(setUser)
}, [])
}
function Dashboard() {
const [stats, setStats] = useState(null)
useEffect(() => {
fetch('/api/user/123')
.then(res => res.json())
.then(data => setStats(data.stats))
}, [])
}
function Settings() {
const [user, setUser] = useState(null)
useEffect(() => {
fetch('/api/user/123')
.then(res => res.json())
.then(setUser)
}, [])
}
// NOW I extract:
export function fetchUserData() {
return fetch('/api/user/123').then(res => res.json())
}
// Components now use the shared utility
function UserProfile() {
const [user, setUser] = useState(null)
useEffect(() => {
fetchUserData().then(setUser)
}, [])
}

The key difference: I waited for the third confirmation that the pattern was stable.

Rule 2: The Deletable Module Test

What it means: A good abstraction should be easily removable. If deleting it breaks 5+ unrelated files, it’s too coupled.

I once created a dateUtils module with every date function I could think of. Six months later, every component in the app imported from it. When I tried to remove unused functions, half the app broke.

My bad abstraction example:

// This used EVERYWHERE
export const dateUtils = {
format: (date) => { /* complex logic */ },
parse: (str) => { /* complex logic */ },
validate: (date) => { /* complex logic */ },
calculateAge: (birthDate) => { /* complex logic */ },
addDays: (date, days) => { /* complex logic */ },
// ... 10+ functions
}
// Components imported the whole thing
import { dateUtils } from './dateUtils'

The deletable test failed: If I removed dateUtils, 20+ files would break. That’s a sign of poor abstraction.

Good abstraction example:

// Specific and focused - only what's needed
export function formatDate(date) {
return new Date(date).toLocaleDateString('en-US')
}
// Components import only what they need
import { formatDate } from './dateUtils'

How I test this: Try deleting the module. If more than 2-3 files need changes, your abstraction is too broad.

Rule 3: One Level Deep Abstraction

What it means: Don’t nest utilities. Your utility layer should serve components directly, not other utilities.

I fell into this trap with API helpers. I created nested objects for “clean” organization:

// My over-engineered mess:
export const apiHelpers = {
responseHandlers: {
dataExtractors: {
extractUserData: (response) => response.data.user,
extractError: (response) => response.error
},
validateResponse: (response) => {
// uses dataExtractors...
}
},
makeRequest: (url) => {
// uses responseHandlers...
}
}
// Usage was confusing:
apiHelpers.responseHandlers.dataExtractors.extractUserData(response)

The problem: When I needed to change the response structure, I had to update three levels of abstractions. The indirection made debugging painful.

Single-level approach:

// Direct utilities only
export function extractUserData(response) {
return response.data.user
}
export function validateResponse(response) {
return response.success && response.data
}
export async function makeRequest(url) {
const response = await fetch(url)
return validateResponse(response) ? extractUserData(response) : null
}
// Simple usage:
const user = await makeRequest('/api/user')

When NOT to Abstract (My Warning Signs)

I’ve learned to avoid abstraction when:

  1. Code exists in only 1-2 places: The cost of abstraction outweighs the benefit
  2. The abstraction is more complex than the original: If your utility is harder to understand than the duplicated code
  3. You’re “abstraction-hunting”: Don’t look for abstractions where none exist
  4. The change is unpredictable: If requirements change frequently, rigid abstractions break easily
  5. It’s “just in case”: Don’t abstract for hypothetical future needs

My worst abstraction story:

// For "future needs" that never came
export const UserManager = {
getInstance: () => new UserManager(),
validateEmail: (email) => { /* regex */ },
hashPassword: (password) => { /* bcrypt */ },
sendWelcomeEmail: (user) => { /* email service */ },
// ... 20+ methods we might never use
}
// Reality: We only needed email validation
function validateEmail(email) {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
}

This 200-line class sat unused for two years. When we finally needed email validation, I had to dig through layers of unnecessary complexity.

The Trial and Error Process

Here’s my mental checklist before abstracting:

□ Have I seen this SAME pattern 3+ times?
□ Can I delete this module if needed?
□ Is the abstraction simpler than the original code?
□ Are requirements stable for this feature?
□ Will other teams actually use this?

What I do instead of abstracting early:

  1. Duplicate the code - First, make it work
  2. Watch for patterns - Let the code evolve naturally
  3. Extract when confirmed - After 3+ stable occurrences
  4. Keep it small - Start with minimal abstraction
  5. Refactor as needed - Grow the abstraction gradually

The Balance: Working Code vs Clean Code

I used to think clean code was always better. Now I know:

Production code has two requirements:
1. It must work correctly
2. It must be maintainable

Good abstractions serve both purposes. Bad abstractions prioritize theoretical perfection over practicality.

My philosophy:

  • First: Make the code work
  • Then: Make it easy to change
  • Finally: Only when you have 3+ instances, extract shared logic

Remember that your coworkers need to understand and maintain the code. An elegant but broken abstraction is worse than simple duplicated code that works.

Conclusion: Intentional Simplicity

The key to proper abstraction is delayed extraction. Wait for patterns to emerge naturally, don’t force them. Follow the three-place rule, ensure deletability, and keep your abstractions shallow.

Overengineering happens when we prioritize theoretical perfection over practical solutions. In production applications, working code that’s slightly duplicated is better than elegant code that’s impossible to maintain.

Apply these rules consistently, and you’ll strike the perfect balance between clean code and unnecessary complexity.

Final Words + More Resources

My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me

Here are also the most important links from this article along with some further resources that will help you in this scope:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments