zobic.io
ArchitectureRefactoring

83% Less Memory & 200% Faster API Performance

83% Less Memory & 200% Faster API Performance
Project Date2024-01-10
Tech Stack
JavaSpring BootHibernateJProfilerDesign Patterns

#The Context

I was originally tasked with implementing a CSV Import feature and fixing consistency bugs in the existing Item Duplication workflow.

However, as I mapped the requirements, I realized these features shared the same DNA. They weren't separate tasks; they were victims of the same architectural flaws. Digging deeper into the legacy codebase, I uncovered four critical issues that had been compounding for years.

Notice

The code examples provided herein are entirely synthetic and created solely for this case study. They do not represent or contain any proprietary source code from the actual project.

#A Silent Performance Killer

I was stress-testing the feature locally using Grafana k6, while analyzing the application with IntelliJ’s Profiler to capture heap dumps. To my surprise, the flame graph showed that a simple Item conversion function was dominating the execution time.

This made no sense. This function wasn't doing heavy computation; it was simply iterating through a list of strings and parsing them into a custom data structure. Even with a large dataset, it should have been near-instantaneous.

Suspicious, I dug into the conversion logic and found the culprit buried in a helper class that was being called inside the loop:

package io.zobic.http;
 
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
 
@Component
@RequiredArgsConstructor
public class RequestContext {
 
    private final UserRepository userRepository;
 
    private final ThreadLocal<JwtDetails> jwtDetails = new ThreadLocal<>();
 
    public UUID getUserId() {
        return jwtDetails.get().userId();
    }
 
    // Public getters used across Validators, Services, and Mappers
    ...
 
    /**
     * Retrieves the current user from the database.
     *
     * <p><strong>Performance Warning:</strong> This method does not implement request-scoped caching.
     * Every invocation triggers a new database SELECT query. If this method is called multiple
     * times within a single request lifecycle (e.g., for currency, region, and status),
     * it will result in redundant database round-trips.</p>
     */
    private User fetchUser() {
        return userRepository.findById(getUserId())
                .orElseThrow(() -> new UserNotFoundException());
    }
}

The bottleneck wasn't the parsing logic—it was a hidden N+1 query. The loop called RequestContext.getCurrency() for every item in the list. Because RequestContext lacked caching, every call triggered a fresh database lookup. This revealed a systemic flaw: since RequestContext was used everywhere, any part of the app needing user details more than once was silently hammering the database with redundant queries.

public class RequestContext {
 
    // I added this ThreadLocal to act as per-thread storage within our existing context
    private static final ThreadLocal<User> userCache = new ThreadLocal<>();
 
    public static Currency getCurrency() {
        return getUser().getCurrency();
    }
 
    /**
     * I implemented Lazy Initialization here to fetch the User only once per request.
     */
    private static User getUser() {
 
        final var cachedUser = userCache.get();
 
        if (cachedUser == null) {
            cachedUser = fetchUserFromDatabaseOrService(); // The original expensive call
            userCache.set(cachedUser); // I cache it here for future calls
        }
        return cachedUser;
    }
}

The Fix & Reflection To be clear, the solution wasn't some engineering marvel—it was a standard caching pattern. But the impact was disproportionate to the effort. This bottleneck had been dormant for years, silently chipping away at the application's throughput until the profiler finally exposed it.

Memoization

I applied Memoization inside the getUser() logic. I modified the class so it remembers the result of the expensive lookup. The first call pays the cost; every subsequent call in the loop (N-1 calls) is near-instant because it simply reads from memory.


It serves as a reminder that technical debt is rarely malicious. In any long-standing codebase, as teams rotate and deadlines press, "convenience" code like this inevitably slips in. The real achievement wasn't the complex code I wrote, but the decision to measure the system under load rather than assuming it was working as intended.

#Duplicate Code

#Logic drift

Core business rules were fragmented across five isolated features.

Because they evolved separately, logic was copy-pasted; fixing a bug in one workflow often left the others broken.


I recognized that these distinct features were actually polymorphic variations of a single operation:

Import
TextAny Item

Parses raw text and runs it through the conversion logic.

Duplicate
Item AItem A

Identity copy. Creates an exact clone of the source.

Move
Item AItem A

Clones the item to the new parent, then deletes the source.

Conversion
Item AItem B

Transforms data structure (e.g. Task to Project) ± deletion.

I decoupled the core business rules from the execution layer, stripping away all side effects to create pure, reusable logic. Behaviors like Database Commits, Live Updates, or Notifications are now controlled via configuration flags passed by the specific consumer. This ensures that the logic is reusable across distinct features, meaning a single bug fix in the core rules automatically propagates to stabilize Import, Duplicate, and Conversion simultaneously.

#Missing Dry Run

The system lacked "Dry Run" capabilities because business logic was inextricably mixed with side effects like database calls and notifications.

The "Create-then-Delete" Anti-Pattern

Entities were coupled to persistence. This forced the 'Preview/Dry run' feature to act as a database 'commit-and-rollback' operation.

The Refactor I decoupled the architecture by extracting pure calculations into ConversionService. This split between logic and execution allowed the same core code to be reused across consumers—safely powering both stateful commits and read-only previews without code duplication.

#Entity-Driven Logic

The system treated Hibernate entities as plain mutable data structures rather than managed persistence objects.

This led to a critical stability issue: modification by reference. By altering the state of existing database proxies to create new items, the system confused the ORM, leading to unpredictable behavior where the original items were often accidentally modified alongside the new ones.

package io.zobic.usecase.duplication;
 
@Service
@RequiredArgsConstructor
public class DuplicateItemsUseCase {
 
    private final ItemRepository itemRepository;
 
    @Transactional
    public ItemDuplicationResult duplicate(final ItemDuplicationCommand command) {
        final var itemIds = command.getItemIds();
        final var items = itemRepository.findAll(itemIds);
 
        // unexpected behvaiour would start happening when modifiying hibernate proxy class
        items.forEach(item -> item.setId(null));
        // This would delete subscriptions from previos entity
        items.forEach(item -> item.setSubscriptions(null));
    }
}

Inflexibility: Schema-Driven Logic

Business rules became dictated by database constraints rather than actual needs. The schema accidentally became the API contract.

Vendor Lock-in

Domain rules were bound to Hibernate. Optimizing bottlenecks with raw SQL (jOOQ) required rewriting business logic, not just the query.

Data Integrity Risk

Hibernate mandates mutable objects, preventing immutable data structures. This created 'Silent Mutation' bugs where data changed unexpectedly.

And even worse these entities would have references to other entities inside them lazily loaded but non the less there are id refrences which would cause a variaty of side effects

Hibernate returns "Proxy" classes

When you start querying data, Hibernate returns proxy classes. They look and feel like your own objects, but they have active interceptors tethered to the database session.

Ideally, I would have refactored the entire domain to use immutable Records. However, given the legacy codebase's deep coupling, a "Big Bang" refactor would have been too risky and time-consuming.

Hybrid Approach
  1. New Logic: All new components (like the Import feature and the Dry Run calculator) use strict, immutable Java Records.

  2. Legacy Interop: Where I was forced to interact with legacy service layers that expected Entities, I enforced a "Copy-Constructor" pattern.

#Summary & Reflection

My directive was to ship a feature, not rewrite the application. However, I recognized that building on top of a fractured foundation would only compound the existing technical debt.

I applied the "Boy Scout Rule" with a commercial mindset: leave the code cleaner than you found it, but don't burn down the forest to do it.

A full "from-scratch" refactor would have been a massive time-sink with diminishing returns. Instead, I focused strictly on high-ROI improvements that served the new feature while stabilizing the old ones.

The Takeaway: Perfect is the enemy of done. By choosing incremental stability over architectural perfection, I was able to deliver immediate business value—fixing critical bugs and boosting performance—without stalling the release cycle.

Filip Zobic

Scaling through complexity?

Startups need velocity; enterprises need stability. I help teams do both by pragmatically paying down technical debt—ensuring you can ship new features today without breaking the foundation of yesterday.