✅ Memoization with Lombok Lazy Getters Link to heading

Memoization is a local optimization pattern: compute a value once, store it, and reuse it. It is not a shared cache and it is not a persistence layer. It stops you from repeating expensive work during a short window of time.

In web apps, that window is often a single request. The same ID might be needed by multiple services, and a client call can fire more than once if you are not careful. Memoization keeps that from happening, which cuts latency and reduces unnecessary outbound calls. It also keeps the value consistent within the request, while still allowing the underlying system to change between requests.

That is the key difference from a cache. A cache trades consistency for speed across requests by design. Request‑level memoization keeps consistency within a single request and limits any potential drift to that request window.

In Spring, a request‑scoped bean plus Lombok’s @Getter(lazy = true) gives you a clean, minimal way to do this without standing up cache infrastructure.


🧭 The Idea Link to heading

Create a request‑scoped supplier that resolves a value once, then reuse it across services inside the same request. This keeps call sites clean and avoids duplicate client calls. Because the lazy getter only runs when accessed, expensive calls are deferred until they are needed.

It also keeps your service boundaries cleaner. Instead of threading a user’s service level through multiple layers just so a deep service can do permission checks, execution gating, or logging, you inject a supplier. Each layer focuses on its own work, and the data dependency stays explicit without being passed around as a method argument. Services can request what they need without worrying about duplicate work or side effects in other layers.

This makes refactoring easier. You can add a dependency like “service level” without changing method signatures across the call chain, and unit tests stay simple because the supplier is just another injected dependency.

The same applies to how you resolve the account ID. It might come from a header, a query param, or another request source. With a single supplier, that logic is defined once, tested once, and reused everywhere without reaching back into the request in multiple services.


🧱 1) Start with an AccountIdSupplier Link to heading

Keep the first link in the chain simple:

public interface AccountIdSupplier extends Supplier<Optional<Long>> {}

@RequiredArgsConstructor
public class AccountIdSupplierImpl implements AccountIdSupplier {
    private final HttpServletRequest request;

    @Override
    public Optional<Long> get() {
        return Stream.<Supplier<Optional<Long>>>of(
                this::getAccountIdFromHeader,
                this::getAccountIdFromRequestParam
        )
                .map(Supplier::get)
                .flatMap(Optional::stream)
                .findFirst();
    }

    private Optional<Long> getAccountIdFromHeader() {
        return parseLong(request.getHeader("X-Account-Id"));
    }

    private Optional<Long> getAccountIdFromRequestParam() {
        return parseLong(request.getParameter("accountId"));
    }

    private Optional<Long> parseLong(String value) {
        if (value == null) {
            return Optional.empty();
        }
        try {
            return Optional.of(Long.parseLong(value));
        } catch (NumberFormatException e) {
            return Optional.empty();
        }
    }
}

🧱 2) Memoize Account Details Link to heading

Use Lombok’s lazy getter to compute once per request:

@RequiredArgsConstructor
public class AccountDetailsSupplier {
    private final AccountIdSupplier accountIdSupplier;
    private final AccountDetailsService accountDetailsService;

    @Getter(lazy = true)
    private final Optional<AccountDetails> accountDetails =
            resolveAccountDetails();

    private Optional<AccountDetails> resolveAccountDetails() {
        return accountIdSupplier.get()
                .flatMap(accountDetailsService::fetchDetails);
    }
}

🧱 3) Memoize Subscription Level Separately Link to heading

If subscription or purchase data lives elsewhere, memoize it separately and reuse the same account ID:

@RequiredArgsConstructor
public class SubscriptionLevelSupplier {
    private final AccountIdSupplier accountIdSupplier;
    private final SubscriptionService subscriptionService;

    @Getter(lazy = true)
    private final Optional<SubscriptionLevel> subscriptionLevel =
            resolveSubscriptionLevel();

    private Optional<SubscriptionLevel> resolveSubscriptionLevel() {
        return accountIdSupplier.get()
                .flatMap(subscriptionService::fetchLevel);
    }
}

🧱 4) Wire It as Request Scope Link to heading

Each request should get its own instance, and the memoized value should live only for that request.

I prefer constructor injection over field injection or @Autowired. It makes dependencies explicit and keeps unit tests simple.

@Configuration
public class SupplierConfig {

    @Bean
    @Scope(value = WebApplicationContext.SCOPE_REQUEST,
           proxyMode = ScopedProxyMode.INTERFACES)
    public AccountIdSupplier accountIdSupplier(HttpServletRequest request) {
        return new AccountIdSupplierImpl(request);
    }

    @Bean
    @Scope(value = WebApplicationContext.SCOPE_REQUEST,
           proxyMode = ScopedProxyMode.TARGET_CLASS)
    public AccountDetailsSupplier accountDetailsSupplier(AccountIdSupplier accountIdSupplier,
                                                         AccountDetailsService accountDetailsService) {
        return new AccountDetailsSupplier(accountIdSupplier, accountDetailsService);
    }

    @Bean
    @Scope(value = WebApplicationContext.SCOPE_REQUEST,
           proxyMode = ScopedProxyMode.TARGET_CLASS)
    public SubscriptionLevelSupplier subscriptionLevelSupplier(AccountIdSupplier accountIdSupplier,
                                                               SubscriptionService subscriptionService) {
        return new SubscriptionLevelSupplier(accountIdSupplier, subscriptionService);
    }
}

⚠️ Warnings and Tradeoffs Link to heading

  • This memoizes once per request, not globally.
  • Lazy getters are synchronized on first access; do not use them for heavy cross‑request caching.
  • If the lazy initializer throws, you may see repeated attempts on subsequent calls. Handle errors deliberately.
  • This pattern is best for no‑arg lookups tied to the request context.
  • It also prevents mid‑request drift. Without memoization, you could grant an action as a paid user, then log it as free if the subscription changes during the request. Rare, but real.

✅ When This Works Well Link to heading

  • You have a per‑request value used by multiple services.
  • You want a consistent value across the request and fewer duplicate calls, without slowing down delivery.
  • You want simple call sites (supplier.getAccountDetails()).

🏁 Wrap-Up Link to heading

This pattern is small, boring, and effective. It keeps request‑level values consistent, reduces duplicate calls, and stays easy to test. It is not a cache substitute; it is a different tool with a different goal: consistency within a single request.