Generically Cache Database Lookups By Id For a project I am working on I had to query a database for a number of "child objects". Now in the mapping POJO for said child objects a reference to the parent is kept. That reference is available in the database as a foreign key constraint.

But if I retrieve a collection of all child elements they all map to vastly less (on the order of 1.5k:1) parent objects.
To prevent accessing the database for each and every parent object lookup I decided to cache the results of parent-object queries for the duration of child-object retrieval.

To accomplish this in a typesafe, but generic way I've written the following class:

public class LookupCache&lt;T,P,R&gt; {
    private final T delegate;

    private final Map&lt;BiFunction&lt;T, P, R&gt;, Map&lt;P, R&gt;&gt; cache = new HashMap&lt;&gt;();


    public static &lt;T,P,R&gt; LookupCache&lt;T,P,R&gt; forObject(T object) {
        return new LookupCache&lt;&gt;(object);
    }

    private LookupCache(T object) {
        this.delegate = object;
    }

    public R lookupOrCompute(BiFunction&lt;T,P,R&gt; compute, P param) {
        Map&lt;P,R&gt; cachedResults = cache.computeIfAbsent(compute, a -&gt; new HashMap&lt;&gt;());
        return cachedResults.computeIfAbsent(param, p -&gt; compute.apply(delegate, p));
    }
}


The usage is pretty simple:


try (ResultSet dbResult = connection.createStatement().executeQuery("SELECT id, parent_id, property FROM child;")) {
    final BiFunction&lt;ParentDAO, Long, Parent&gt; parentLookup = ParentDAO::getById;
    final LookupCache&lt;ParentDAO, Long, Parent&gt; lookupCache = LookupCache.forObject(new ParentDAOImpl());
    List&lt;Child&gt; result = new ArrayList&lt;&gt;();
    while (dbResult.next()) {
        Parent p = lookupCache.lookupOrCompute(parentLookup, dbResult.getLong("parent_id"));
        result.add(new Child(dbResult.getLong("id")
            , Property.fromDbState(result.getLong("property"))
            , p));
    }
    return result;
} // catch clause and default return for error



Note that usage is not up for review here, what I'm significantly more interested in is: 


Is this Cache designed in a way that's intuitive to use?
Am I possibly overcomplicating things by passing the DAO to the Cache instead of capturing it in a lambda?
Anything else I might have missed?
Cache semantic

A cache is something optional. You should be able to disable the cache by always returning "null" in the lookup method without worrying that your algorithm fails. It only should be slower. I don't think your implementation will pass this check. The cache should NOT retreive new data on its own.

Cache scope

A cache has a well defined lookup and object scope. Your implementation has a cached object lookup scope of "method" and a cached object usage scope that is somehow greater. Both should fall into the same scope to satisfy symmetry. Without knowing your UseCase-Layer you most of the time want to have all objects needed available in the UseCase. Refreshing your UseCase will often cause refreshing your cache because you want to process a UseCase on the latest data.

One other thing: Your Child-Objects may not the only Objects that have references on the Parent-Object. Those objects you would have to handle with seperate caches so you cannot take advantage of the already loaded instances. Consider caching on In thhe Business-Layer if you have no Persistence Mapping Tool (JPA). Otherwise you should make use of Entity-Caches.

Lambda expressions

My opinion on lambda expressions is ambivalent. Of course you can have very compact and technically expressive statements. But technically expressive statements may not be semantically expressive. Often they hide bad design. You can compress code that it looks good in one method that would normally be splitted into two methods. But the only reason you may have need for two methods was bad design.

I consider usage of lambda expression rarely: Listener-notifications, mapping JPA objects to Busines-Objects or massive parallel number calculations. I never use lambda expressions to make code "shorter".

Approach

Simplify the data retreiving process:

public class ChildService {

    public List&lt;Child&gt; getChildren() {

        List&lt;Child&gt; result = new ArrayList&lt;&gt;();

        try (ResultSet resultSet = connection.createStatement().executeQuery("")) {

            while (resultSet.next()) {
                result.add(new ChildProxy(resultSet.getLong("parent_id")));
            }

        } catch (SQLException e) {
            ...
        }

        return result;
    }

}


Passing the parent id to the constructor of the Child object and resolving the parent through the DAO:

public class Child {

    protected long parentId;

    public Child(long parentId) {
        this.parentId = parentId;
    }

    public Parent getParent() {
        return new ParentDaoImpl().getById(this.parentId);
    }

}


Derive a Caching enabled ChildProxy from the "Child" and intercept the getParent()-method to check for a cached Parent-Object. If you would have a JPA-Layer you may not need this because OR-Mapper often have their own caching mechanisms that are sufficient for most usecases.

public class ChildProxy extends Child {


    public ChildProxy(long parentId) {
        super(parentId);
    }

    Parent getParent() {

        Parent parent = Cache.lookup(Parent.class, this.parentId);

        if (parent == null) {
            parent = super.getParent();
            Cache.put(this.parentId, parent);
        }

        return parent;
    }

}


Formulating your UseCase:

public class UseCase {


    private final List&lt;Child&gt; children = new ArrayList&lt;&gt;();


    public void init() {
        ...
        this.children.addAll(new ChildService().getChildren());
        ...
    }


    public void reset() {
        Cache.clear();
        this.children.clear();
        init();
    }

    ...

}


Maybe you have to think a little bit more about the caching scope. It's not that easy because most developers tend to simply store expensive objects near their first occurance. 

My advice is: NEVER cache at the first place. ALWAYS formulate your statements as they are NOT EXPENSIVE. After that you can go on with an optimization...

... that should be transparent to the existing implementation

... and has a well defined scope to match the use case requirements