DEV Community

Cover image for The Abstraction Tax
Piotr Borys
Piotr Borys

Posted on

The Abstraction Tax

Junior developers write code that barely works.

Mid-level developers write clean code that solves today's problem.

Senior developers - or at least engineers entering that transitional phase of their careers - sometimes write five layers of interfaces, abstract factories, dynamic configuration engines, and custom event buses for a feature that will literally never change again.

It is a rite of passage, but also a dangerous trap. We have all opened a pull request or stepped into a codebase expecting to fix a two-line bug, only to find ourselves navigating three abstract classes, two strategy interfaces, a generic repository wrapper, and a dynamic dependency injection setup - all just to append a timestamp to a database record.

How did we get here? And more importantly, how do we stop confusing structural complexity with high-quality software engineering?


Why Smart Engineers Over-Engineer

Over-engineering rarely comes from malice or incompetence. In fact, it almost always stems from good intentions combined with a few subtle cognitive traps:

  1. Pattern Worship & Resume-Driven Development: After mastering classic design patterns (Gang of Four, Clean Architecture, DDD), there is a powerful urge to use them everywhere. Applying a complex pattern feels like "doing real engineering" - and looks great on a CV.
  2. Fear of Future Change (Speculative Generality): "What if we switch from PostgreSQL to MongoDB next month? What if we swap Stripe for PayPal and Adyen simultaneously?" We build elaborate provider abstractions today for architectural shifts that never happen tomorrow.
  3. Confusing Flexibility with Quality: We mistake configurable, indirect code for robust code. However, every option, toggle, and generic type parameter exponentially increases the state space you must test, reason about, and maintain.
  4. The Intellectual Boredom Factor: Solving the actual business problem is often straightforward. Building an extensible, plugin-based meta-framework to solve it is far more intellectually stimulating.

The Hidden Cost: The Abstraction Tax

Every layer of indirection you add comes with a price tag that the whole team pays continuously over time.

The Abstraction Tax: The mental energy required for a developer to trace execution through layers of indirection before they can understand what the code actually does.

When a codebase succumbs to over-engineering:

  • Debugging becomes a nightmare: Stack traces jump across six files of pass-through wrappers and indirection layers.
  • Onboarding slows to a crawl: New team members spend weeks learning custom architectural meta-conventions instead of core business domain rules.
  • Refactoring becomes harder, not easier: Ironically, hyper-generalized code is often so rigid in its abstractions that changing a fundamental requirement breaks the entire class hierarchy.
  • Performance takes a silent hit: In systems languages like C++, unneeded dynamic polymorphism (virtual dispatch, vtable lookups) and unnecessary heap allocations prevent compiler inlining and pollute the instruction cache.

A Tale of Two Implementations

Let’s examine a concrete scenario in C++20. Suppose we need a service that fetches user profile data from an external HTTP API and saves it to a local cache.

The Over-Engineered Approach

#include <memory>
#include <string>
#include <format>

// Domain structures
struct UserProfileRaw { std::string id; std::string full_name; };
struct UserProfile    { std::string id; std::string name; };

// 1. Interface for the API Client
template <typename T>
class IUserDataProvider {
public:
    virtual ~IUserDataProvider() = default;
    virtual T fetchPayload(const std::string& id) = 0;
};

// 2. Strategy interface for caching
template <typename T>
class ICacheStrategy {
public:
    virtual ~ICacheStrategy() = default;
    virtual void save(const std::string& key, const T& data) = 0;
};

// 3. Abstract Base Orchestrator
template <typename TInput, typename TOutput>
class BaseUserOrchestrator {
protected:
    std::shared_ptr<IUserDataProvider<TInput>> provider;
    std::shared_ptr<ICacheStrategy<TOutput>> cache;

public:
    BaseUserOrchestrator(
        std::shared_ptr<IUserDataProvider<TInput>> prov,
        std::shared_ptr<ICacheStrategy<TOutput>> csh
    ) : provider(std::move(prov)), cache(std::move(csh)) {}

    virtual ~BaseUserOrchestrator() = default;
    virtual TOutput process(const std::string& id) = 0;
};

// 4. Concrete Strategy Implementation (Redis)
template <typename T>
class RedisCacheStrategy : public ICacheStrategy<T> {
public:
    void save(const std::string& key, const T& data) override {
        redisClient::set(key, data);
    }
};

// 5. Concrete Provider Implementation (HTTP)
class ExternalHttpUserProvider : public IUserDataProvider<UserProfileRaw> {
public:
    UserProfileRaw fetchPayload(const std::string& id) override {
        return httpClient::get<UserProfileRaw>(std::format("/users/{}", id));
    }
};

// 6. Concrete Service Implementation
class UserProfileOrchestrator : public BaseUserOrchestrator<UserProfileRaw, UserProfile> {
public:
    using BaseUserOrchestrator::BaseUserOrchestrator;

    UserProfile process(const std::string& id) override {
        UserProfileRaw raw = provider->fetchPayload(id);
        UserProfile profile = mapToDomain(raw);
        cache->save(std::format("user:{}", id), profile);
        return profile;
    }

private:
    UserProfile mapToDomain(const UserProfileRaw& raw) {
        return UserProfile{ .id = raw.id, .name = raw.full_name };
    }
};

Enter fullscreen mode Exit fullscreen mode

The Cost: Three class templates, two virtual interfaces, an abstract base class, heap allocations via std::shared_ptr, and runtime dispatch overhead through vtables - all for a single fetch and cache operation.

The Pragmatic Approach

#include <string>
#include <format>

struct UserProfileRaw { std::string id; std::string full_name; };
struct UserProfile    { std::string id; std::string name; };

// Clean, direct, procedural execution
UserProfile getUserProfile(const std::string& userId) {
    auto raw = httpClient::get<UserProfileRaw>(std::format("/users/{}", userId));

    UserProfile profile{
        .id = raw.id,
        .name = raw.full_name
    };

    redisClient::set(std::format("user:{}", userId), profile);
    return profile;
}

Enter fullscreen mode Exit fullscreen mode

The Value: Ten lines of code. Zero virtual calls, zero dynamic allocations, perfect inlining potential for the compiler, and complete clarity for anyone reading the code.

If - and only if - you later introduce a second data provider or alternative cache engine, you can extract an interface or introduce a template concept in 60 seconds. Until that day comes, the extra abstraction is pure dead weight.


Signal vs. Noise: Architectural Health Checklist

βš™οΈ Pragmatic Engineering πŸ€– Over-Engineered Architecture
Interfaces introduced when 2+ active implementations exist. Interfaces with only 1 implementation created "just in case".
Duplication tolerated until patterns emerge (Rule of Three). Abstract base classes created before a second subclass exists.
Direct function calls and explicit dependency passing. Custom internal frameworks, event buses, or meta-config engines.
Code designed to be easily replaced or deleted. Code designed to be "infinitely extensible".
Embraces C++ Zero-Overhead Principle. Introduces virtual dispatch and heap allocation indiscriminately.

Rules to Stay Pragmatic

1. Embrace AHA over Premature DRY

Don't extract a shared abstraction the second time you see similar code. Wait until the third distinct use case. As Kent C. Dodds popularized: Avoid Hasty Abstractions (AHA). Duplication is far cheaper than the wrong abstraction.

2. Concrete First, Abstract Later

Write the simplest procedural or functional implementation that works. Get it running and covered by tests. If structural patterns emerge naturally during code review or feature expansion, refactor toward abstractions then. Refactoring concrete code into abstractions is easy; unwinding bad abstractions is painful.

3. YAGNI (You Aren't Gonna Need It)

If a capability isn't required by today's user story or immediate roadmap, do not write code for it. Omit optional parameters, fallback adapters, and plugin architectures designed for speculative futures.

4. Optimize for Deletability

Great code isn't code that can be extended indefinitely without touching it. Great code is code that can be understood in 5 minutes and completely replaced in an hour without breaking unrelated parts of the system.


The Takeaway

True senior engineering capability is not measured by your ability to construct complex, highly generic abstractions that require an architectural diagram to navigate.

It is demonstrated by solving complex domain problems with code so simple, explicit, and direct that a junior developer joining the team can look at it and say: "Oh, that makes total sense."

Before you submit your next Pull Request, ask yourself:

"Am I building this abstraction for tomorrow's reality, or just today's intellectual satisfaction?"

Top comments (0)