DEV Community

Cover image for A Small Java Util: Solve Complex ORM Pain, Build a Clean System
BanyanInBreeze
BanyanInBreeze

Posted on

A Small Java Util: Solve Complex ORM Pain, Build a Clean System

One day, a friend asks you this question: "How would you fetch a one-to-many relationship and map it to Java objects? Simple example: teams and members."

"Easy. Use Spring JPA's JOIN FETCH to join the team and member tables. Simple, and it avoids N+1," you'd answer.

"Or jOOQ's multiset. Plain JDBC JOIN works too, but you need some Java code to group the rows into a Map and then convert to objects," you'd add.

Your friend continues: "Cool. Now add one more level — one member has multiple contact methods. And a team also has a manager. It's a sibling of members on team."

"Also, a team can have multiple projects, and each project has multiple tasks..."

You start doing the math in your head.

JPA's JOIN FETCH can't handle multiple collections well, and risks a Cartesian product explosion. @EntityGraph might work, but it gets messy and fragile. With raw SQL or Spring JDBC, the mapping turns into complex nested loops and grouping logic.

jOOQ is good at multi-level, multi-sibling problems. With multiset() and other helpers it can solve it, but the code inevitably becomes verbose and grows quickly with depth.

Before you answer, your friend adds: "Oh, forgot to mention — contact methods aren't in our local database. They're in an external directory service. You have to call a remote REST API."

Silence.

This is the structure that your friend asks for:

Team
  ├── (one) Manager
  ├── (many) Members
  │     └── (many) ContactMethods (from remote API)
  └── (many) Projects
        └── (many) Tasks
Enter fullscreen mode Exit fullscreen mode

This kind of problem isn't rare. And this is usually where we start writing messy code and praying we never have to maintain it.

It's always a headache handling data with complex relationships. What's worse, and more true today, is that data can come from outside the local database.

Sometimes we make the object model simpler and expose multiple APIs — not because the requirement asks for it, but because the complex version is too painful to implement. By doing this, we throw the ball to the client and say: "It's up to you to join them together."

That's why I built Populator: 340 lines of executable Java in two files. No annotations, no framework magic, no third-party dependencies — just a handful of plain Java classes and functions.

It works with any data access framework — Spring JPA, JDBC, jOOQ, MyBatis. It assembles objects fetched via your framework or remote APIs. It supports concurrent population and enables clean, reusable code.

Performance? We'll get to it later in this article.

Table of Contents

The Core Idea

The frameworks mentioned above try to solve the problem with a single query. Another approach is to run multiple queries and stitch the results together. Let's walk through it using the team-member example:

Team
  └── (many) Members
Enter fullscreen mode Exit fullscreen mode

Here's an example that illustrates the idea (this is not Populator yet):

class Team {
    // Fields mapped to columns in the team table
    Integer id;
    String name;

    List<Member> members; // To be populated

    // ... getters/setters/constructors
}

class Member {
    // Fields mapped to columns in the member table
    Integer id;
    String name;
    Integer teamId;

    // ... getters/setters/constructors
}

/* The data access functions you'd write with Spring JPA, JDBC, jOOQ, etc. */

// SQL: SELECT team.id, team.name FROM team WHERE ...
List<Team> findTeamsBySomeCriteria(/* ... some criteria */)  { ... }

// SQL: SELECT member.id, member.name FROM member WHERE team_id IN (:teamIds)
List<Member> findMembersByTeamIds(Collection<Integer> teamIds)  { ... }

/* The main function */
List<Team> findTeamsWithMembersPopulated(/* ... some criteria */) {
    List<Team> teams = findTeamsBySomeCriteria(/* ... some criteria */);
    return populateTeamsWithMembers(teams);
}

/* The CORE IDEA */
List<Team> populateTeamsWithMembers(List<Team> teams) {
    // 1. Collect all team IDs
    Set<Integer> teamIds = teams.stream()
                        .map(Team::getId).collect(Collectors.toSet());

    // 2. Batch-load all members for those teams
    List<Member> members = findMembersByTeamIds(teamIds);

    // 3. Group members by teamId
    Map<Integer, List<Member>> memberMap = 
        members.stream().collect(Collectors.groupingBy(Member::getTeamId));

    // 4. Assign members back to each team
    teams = teams.stream()
        // Note: assumes team.setMembers(...) returns "this" instead of "void"
        .map(team -> team.setMembers(memberMap.get(team.getId())))
        .toList();

    return teams;
}
Enter fullscreen mode Exit fullscreen mode

Nothing new so far — you've probably written something like this yourself. But let's continue.

Introducing Populator

At its core, Populator does exactly what populateTeamsWithMembers() does — but with generics instead of hardcoded types like Team, Member, and Integer.

Here's how to do the same thing with Populator:

teams = Populator.of(teams)
        .populateMany(Team::getId, Member::getTeamId,
            this::findMembersByTeamIds, Team::setMembers)
        .run();
Enter fullscreen mode Exit fullscreen mode

What this does:

  • Creates a Populator instance for teams
  • Calls populateMany() to describe how to populate them. It takes four arguments:
    • Team::getId and Member::getTeamId — the fields that link the two types
    • The finder method to load members by team IDs: findMembersByTeamIds
    • The setter that assigns the loaded members to a team
  • Call run() to execute

This assumes Team.setMembers() returns this (fluent style). If you follow the traditional JavaBean convention where setters return void, use a lambda instead:

teams = Populator.of(teams)
    .populateMany(Team::getId, Member::getTeamId,
        this::findMembersByTeamIds,
        (team, members) -> { team.setMembers(members); return team; }
    )
    .run();
Enter fullscreen mode Exit fullscreen mode

Use populateMany() for one-to-many relationships. For one-to-one or many-to-one, use populateOne().

Wondering what the full source code looks like? It's here on GitHub:

banyan-in-breeze/populator-java

Handle Multi-Sibling, Multi-Level Structure

Now let's add contact methods and managers:

Team
  ├── (one) Manager
  └── (many) Members
        └── (many) ContactMethods (from remote API)
Enter fullscreen mode Exit fullscreen mode

Here is the model:

class Team {
    Integer id;
    String name;

    Manager manager; // to be populated
    List<Member> members; // to be populated

    // ... getters/setters/constructors
}

class Member {
    Integer id;
    String name;
    Integer teamId;
    String employeeId; // new: reference to external directory

    List<ContactMethod> contactMethods; // to be populated

    // ... getters/setters/constructors
}

class Manager {
    Integer id;
    String name;
    Integer teamId;

    // ... getters/setters/constructors
}

class ContactMethod {
    String employeeId;
    String type; // e.g., PHONE, EMAIL, ..
    String value; // e.g., 555-123-4567, or john@example.com
}
Enter fullscreen mode Exit fullscreen mode

And the data query functions:

// DB query to find managers
List<Manager> findManagersByTeamIds(Collection<Integer> teamIds) { ... }

// Remote API call to external directory
List<ContactMethod> findContactMethodsByEmployeeIds(
                        Collection<String> employeeIds) { ... }
Enter fullscreen mode Exit fullscreen mode

And the main function that fetches and assembles everything:

List<Team> findTeamsWithEverythingPopulated(/* ... some criteria */) {
    List<Team> teams = findTeamsBySomeCriteria(/*... some criteria */);

    teams = Populator.of(teams)
        // Level 1, sibling 1: managers from DB
        .populateOne(Team::getId, Manager::getTeamId, 
                    this::findManagersByTeamIds, Team::setManager)

        // Level 1, sibling 2: members from DB
        .populateMany(
            Team::getId,
            Member::getTeamId,
            // Use a lambda so we can populate members with contact methods inside
            teamIds -> {
                var members = this.findMembersByTeamIds(teamIds);

                // Level 2: stitch contact methods before returning
                return Populator.of(members)
                    .populateMany(Member::getEmployeeId,
                            ContactMethod::getEmployeeId, 
                            // Remote API call works just like a DB call
                            this::findContactMethodsByEmployeeIds,
                            Member::setContactMethods)
                    .run();
            },
            Team::setMembers
        )
        .run();

    return teams; // fully populated
}
Enter fullscreen mode Exit fullscreen mode

The comments make it a bit noisy. Here's the core part without them:

teams = Populator.of(teams)
    .populateOne(Team::getId, Manager::getTeamId, 
                this::findManagersByTeamIds, Team::setManager)
    .populateMany(Team::getId, Member::getTeamId,
        teamIds -> {
            var members = this.findMembersByTeamIds(teamIds);
            return Populator.of(members)
                .populateMany(Member::getEmployeeId,
                        ContactMethod::getEmployeeId, 
                        this::findContactMethodsByEmployeeIds,
                        Member::setContactMethods)
                .run();
        },
        Team::setMembers
    )
    .run();
Enter fullscreen mode Exit fullscreen mode

With Populator:

  • No big joins — just simple finder functions. They are reusable, too.
  • A remote API call is embedded seamlessly, just like a DB query.
  • The hierarchy is assembled in one place and can expand to any number of levels.

You might be thinking:

  • Four arguments of method references or lambdas in each populate call, mixed in multiple levels — it looks messy.
  • Splitting one query into many means more round-trips, so performance will suffer.

These are fair concerns. We'll address performance later in this article.

As for the messy look, see the next section.

Use PopulateParams for Cleaner, Reusable Code

PopulateParams is a wrapper for the four parameters in populateOne() and populateMany().

Instead of passing two ID getters, a finder, and a setter separately, you can bundle them into a PopulateParams object and pass it to populate().

Define it once and reuse it across your project.

Using PopulateParams, the team-member example becomes:

PopulateParams<Team, Member, Integer> paramsTeamWithMembers() {
    return PopulateParams.ofMany(Team::getId, Member::getTeamId,
                    this::findMembersByTeamIds, Team::setMembers);
}

List<Team> findTeamsWithMembersPopulated(/* ... some criteria */) {
    List<Team> teams = findTeamsBySomeCriteria(/* ... some criteria */);

    return Populator.of(teams)
        .populate(paramsTeamWithMembers())
        .run();
}
Enter fullscreen mode Exit fullscreen mode

For one-to-one and many-to-one relationships, use PopulateParams.ofOne() instead.

Use ChildModifier for Ultimate Reusability

PopulateParams makes population logic reusable and code cleaner.

But how do we handle members with contact methods? Like this?

PopulateParams<Team, Member, Integer> paramsTeamWithMembers() {
    return PopulateParams.ofMany(Team::getId, Member::getTeamId,
            teamIds -> {
                // Add child population here?
                List<Member> members = findMembersByTeamIds(teamIds);
                return Populator.of(members)
                    .populate(paramsMemberWithContactMethods())
                    .run();
            }
            Team::setMembers);
}
Enter fullscreen mode Exit fullscreen mode

That's not a good idea. It kills reusability. What if you want to reuse paramsTeamWithMembers() without populating contact methods, or with something else entirely?

Worse, from the name alone you'd have no idea paramsTeamWithMembers() silently populates members with contact methods, whether you need them or not.

The clean solution is ChildModifier, which comes with Populator. It's a simple interface like Function<T, U>, but designed for Populator — here's the actual source from the GitHub repository (you don't need to write this yourself):

public static interface ChildModifier<C> {
    // Passthrough: returns the list untouched
    static <C> ChildModifier<C> none() {
        return children -> children;
    }

    // Modify the child collection and/or return it
    List<C> modify(List<C> children);
}
Enter fullscreen mode Exit fullscreen mode

Add it to the params creator function:

PopulateParams<Team, Member, Integer> paramsTeamWithMembers(
            ChildModifier<Member> childModifier) {
    return PopulateParams.ofMany(Team::getId, Member::getTeamId,
            teamIds -> childModifier.modify(findMembersByTeamIds(teamIds)),
            Team::setMembers);
}
Enter fullscreen mode Exit fullscreen mode

Now paramsTeamWithMembers() does nothing extra to members by itself — it lets the caller decide.

To populate contact methods:

teams = Populator.of(teams)
    .populate(paramsTeamWithMembers(
        members -> Populator.of(members)
            .populate(paramsMemberWithContactMethods())
            .run()
    ))
    .run();
Enter fullscreen mode Exit fullscreen mode

To skip child population:

teams = Populator.of(teams)
    .populate(paramsTeamWithMembers(ChildModifier.none()))
    .run();
Enter fullscreen mode Exit fullscreen mode

Full Implementation of the Team Structure

Now let's use Populator with PopulateParams and ChildModifier to implement what your friend asked for:

Team
  ├── (one) Manager
  ├── (many) Members
  │     └── (many) ContactMethods (from remote API)
  └── (many) Projects
        └── (many) Tasks
Enter fullscreen mode Exit fullscreen mode

The models:

// Model classes mapped to database tables
// Each contains fields mapped to columns plus references to children
class Team { ... }
class Manager { ... }
class Member { ... }
class Project { ... }
class Task { ... }

// Model class mapped to remote API result
class ContactMethod { ... }
Enter fullscreen mode Exit fullscreen mode

The simple finders:

// Each is a simple query, e.g.:
//   SELECT ... FROM manager WHERE team_id IN (:teamIds)
// Implemented with Spring JPA, JDBC, jOOQ, MyBatis, etc.
List<Manager> findManagersByTeamIds(Collection<Integer> teamIds) {...}
List<Member> findMembersByTeamIds(Collection<Integer> teamIds) {...}
List<Project> findProjectsByTeamIds(Collection<Integer> teamIds) {...}
List<Task> findTasksByProjectIds(Collection<Integer> projectIds) {...}

// Remote API client:
List<ContactMethod> findContactMethodsByEmployeeIds(
    Collection<String> employeeIds) {...}
Enter fullscreen mode Exit fullscreen mode

The small PopulateParams creators:

// Each is a one-liner: PopulateParams.ofMany() / ofOne()
PopulateParams<Team, Manager, Integer> paramsTeamWithManager() {...}
PopulateParams<Team, Member, Integer> paramsTeamWithMembers(
    ChildModifier<Member> childModifier) {...}
PopulateParams<Member, ContactMethod, String> paramsMemberWithContactMethods() {...}
PopulateParams<Team, Project, Integer> paramsTeamWithProjects(
    ChildModifier<Project> childModifier) {...}
PopulateParams<Project, Task, Integer>  paramsProjectWithTasks() {...}
Enter fullscreen mode Exit fullscreen mode

The single place that fetches and assembles everything:

List<Team> findTeamsWithEverythingPopulated(/* ... some criteria */) {
    List<Team> teams = findTeamsBySomeCriteria(/* ... some criteria */);

    return Populator.of(teams)
        .populate(paramsTeamWithManager())
        .populate(paramsTeamWithMembers(
            members -> Populator.of(members)
                .populate(paramsMemberWithContactMethods())
                .run()
        ))
        .populate(paramsTeamWithProjects(
            projects -> Populator.of(projects)
                .populate(paramsProjectWithTasks())
                .run()
        ))
        .run();
}
Enter fullscreen mode Exit fullscreen mode

The advantages are clear:

  • Big, complex problems become small building blocks — models, finders, and PopulateParams creators.
  • Much lower cognitive load.
  • Assembling everything in one place gives you a full view without diving into other functions.
  • Unlike big, specific, hard-to-reuse queries, these building blocks are reusable across the whole application.
  • Aligns with clean-code principles: separation of concerns, small functions, and single responsibility.

Reusing the Building Blocks

On reusability: if you only need teams, projects, and tasks, just assemble the relevant blocks:

List<Team> teams = findTeamsBySomeCriteria(...);
teams = Populator.of(teams)
        .populate(paramsTeamWithProjects(
            projects -> Populator.of(projects)
                .populate(paramsProjectWithTasks())
                .run()
        ))
        .run();
Enter fullscreen mode Exit fullscreen mode

If you later add a task_sprint table for sprints per task, it's easy to extend:

// Add simple building blocks for task sprints:
// a model, a PopulateParams creator, and a find-by-ids method

List<Team> teams = findTeamsBySomeCriteria(...);
teams = Populator.of(teams)
        .populate(paramsTeamWithProjects(
            projects -> Populator.of(projects)
                .populate(paramsProjectWithTasks(
                    // Populate tasks with sprints here
                    tasks -> Populator.of(tasks)
                    .populate(paramsTaskWithSprints())
                    .run()
                ))
                .run()
        ))
        .run();
Enter fullscreen mode Exit fullscreen mode

Populator's Concurrent Capability

Populator has built-in parallel processing to improve fetch performance. To use it, just change .run() to .runParallel():

teams = Populator.of(teams)
    .populate(paramsTeamWithManager())
    .populate(paramsTeamWithMembers(
        members -> Populator.of(members)
            // Added one more sibling — title —
            // to show runParallel() works at sub-levels too
            .populate(paramsMemberWithTitle())
            .populate(paramsMemberWithContactMethods())
            .runParallel() // <-- parallel
    ))
    .populate(paramsTeamWithProjects(
        projects -> Populator.of(projects)
            .populate(paramsProjectWithTasks())
            .run()
    ))
    .runParallel(); // <-- parallel
Enter fullscreen mode Exit fullscreen mode

You can also call .runWithMode(boolean parallel) to switch between sequential and parallel dynamically.

When you call runParallel(), Populator invokes all finder functions concurrently using an internal thread pool. This saves time because finders are often the slow part. Once all child datasets are fetched, Populator sets them into their parents one by one. So if the parent is immutable and uses withers (e.g., team.withMembers(members)) instead of setters, calling the withers sequentially ensures no child data is lost.

The default pool is Executors.newCachedThreadPool() — for Java 8 compatibility, and to avoid deadlock if runParallel() is used at both parent and child level. On Java 21+, replace it with a virtual-thread pool at startup:

Populator.setGlobalExecutor(Executors.newVirtualThreadPerTaskExecutor());
Enter fullscreen mode Exit fullscreen mode

The README in the GitHub repository also covers how to use concurrency with transactions — omitted here for brevity.

Data Model Classes: Mutable, Immutable, and Semi-mutable

Populator works with both mutable and immutable models. As long as the functions you pass do what they're supposed to do, Populator is happy.

Take this simple example:

teams = Populator.of(teams)
    .populateMany(Team::getId, Member::getTeamId,
            this::findMembersByTeamIds,
            // Using lambda form of Team::setMembers for clarity
            (team, members) -> team.setMembers(members)
    )
    .run();
Enter fullscreen mode Exit fullscreen mode

Populator expects the last parameter — (team, members) -> team.setMembers(members) here — to attach children to the parent and return a (not necessarily the) parent. That returned parent is what goes into the final result. So the function can return the same instance (this) or a copy. That's where you can pass a wither (e.g., team.withMembers(members)) for an immutable class or record.

However, a wither copies the parent on every call. If you have 100 parents each populated with 3 children, that's 300 extra objects for GC to clean up. It matters at scale or in high-throughput systems.

While mutable classes are memory-efficient, you can add more immutability. A good middle ground is semi-mutable models: make native fields that map to table columns final, and keep child references mutable via setters. If you use PopulateParams, put the creator classes in the same package as the models and make the setters package-private instead of public.

Find Data by IDs in Batches: BatchFinder

With Populator, your app relies heavily on batch lookups like findMembersByIds(Collection<Integer> ids). Passing a large or unpredictable number of IDs directly to the database can cause two problems:

  1. Parameter limits: Queries fail if an IN clause exceeds the DB limit (e.g., Oracle's 1,000-item limit).
  2. Cache bloat: A varying ID count (1 to 1,000) forces the database to recompile execution plans, bloating the prepared statement cache.

BatchFinder, which comes with Populator, solves both. It splits IDs into standardized batch sizes, calls the finder for each batch, and merges the results. It also minimizes the number of batches to reduce DB roundtrips.

Example:

public List<Member> findMembersByIds(Collection<Integer> ids) {
    return BatchFinder.find(ids, this::localFindMembersByIds);
}

// The actual DB query
private List<Member> localFindMembersByIds(Collection<Integer> ids) { ... }
Enter fullscreen mode Exit fullscreen mode

Best practice is to encapsulate BatchFinder inside your repository. If you use Spring Data JDBC/JPA repositories, add a default interface method for findMembersByIds() that delegates to BatchFinder, while localFindMembersByIds() is the actual @Query method.

Rule of thumb: use BatchFinder whenever the ID count can exceed 200.

Performance Analysis

Performance is a complicated question with many moving variables.

Populator uses multiple smaller queries to do what a single query with multiple JOINs does. So what does that mean for performance?

One thing to clarify first: we are not discussing the performance of Populator itself, since it is just a stitching tool. We are comparing the multiple-small-query approach versus the single-query approach. The multiple-small-query approach has long existed — Populator just makes it extremely easy to use.

1. Comparing the Approaches with Examples

Let's look at the two biggest variables:

  • Roundtrips to the database
  • Amount of data fetched

Assume you want to fetch 10 teams that have a total of 100 members (an average of 10 members per team).

The multiple-small-query approach fetches data from the team and member tables using 2 separate queries. The single-query approach joins the 2 tables together in 1 query.

  • The Multiple-Small-Query Approach:
    • Roundtrips: 2 trips (one for teams, one for members).
    • Data Fetched: 110 data items = 10 teams + 100 members.
  • The Single-Query Approach (JOIN):
    • Roundtrips: 1 trip.
    • Data Fetched: 200 data items = 100 team records (duplicated inside 100 joined rows) + 100 member records.

The multiple-small-query approach needs 1 extra network trip, but it cuts the total data payload by 90 data items (nearly 50%).

What happens if the dataset grows to 100 teams and 1,000 members?

  • The Multiple-Small-Query Approach:
    • Roundtrips: 2 trips.
    • Data Fetched: 1,100 data items = 100 teams + 1,000 members.
  • The Single-Query Approach (JOIN):
    • Roundtrips: 1 trip.
    • Data Fetched: 2,000 data items = 1,000 team records (duplicated inside 1,000 joined rows) + 1,000 member records.

The multiple-small-query approach still needs 1 extra network trip, but it cuts the total data payload by 900 data items (nearly 50%).

Let's add a third level: each member has 1 of 10 possible professional titles.

  • The Multiple-Small-Query Approach:
    • Roundtrips: 3 trips (one for teams, one for members, one for titles).
    • Data Fetched: 1,110 data items = 100 teams + 1,000 members + 10 titles.
  • The Single-Query Approach (JOIN):
    • Roundtrips: 1 trip.
    • Data Fetched: 3,000 data items = 1,000 team records (duplicates) + 1,000 member records + 1,000 title records (duplicates).

Now, the multiple-small-query approach needs 2 extra trips, but it reduces the total data payload by 1,890 data items (nearly 63%).

2. Trends and Trade-offs

To summarize the trade-off:

  • The multiple-small-query approach takes more roundtrips but fetches significantly less data.
  • The single-query approach takes fewer roundtrips but fetches a much heavier, duplicate-filled dataset.

With the single-query approach, data volume increases fast as the following 3 values increase:

  • The number of nesting levels (table joins).
  • The number of rows fetched in each table.
  • The size/width of each data row.

With the multiple-small-query approach, the number of roundtrips increases slowly as the following 2 values increase:

  • The number of nesting levels: add 1 roundtrip for 1 extra level.
  • The number of IDs: add 1 roundtrip for 1 extra batch.

What More Roundtrips Mean:

  • Network Latency: If the app and DB are far apart, this hurts. But in prod they usually sit in the same local network, so a roundtrip is only 0.3 ms to 2 ms, sometimes as low as 0.1 ms. 3 queries add 0.3 ms to 6 ms, 10 queries add 1 ms to 20 ms — minor, but noticeable at the slow end. (But a single query joining 10 tables with lots of duplicate data can easily cost you far more than 20 ms.)

What a Heavier Data Volume Means:

  • CPU Burden: Both the DB server and the app server spend more CPU cycles creating duplicate data objects.
  • Garbage Collection: Short-lived, duplicate data triggers more frequent JVM Garbage Collection cycles.
  • Network Bandwidth: Large datasets use up more network bandwidth.

Additional Facts:

  • Multiple small queries rely on simple lookups, typically by primary key (WHERE ID IN (?)), which databases execute efficiently.
  • Complex joins burn heavy DB CPU and memory for joining, sorting, and deduplicating. When a centralized DB hits its hardware limit, scaling vertically gets expensive fast.

Two Factors We Ignore Here:

  1. Indexing: We assume proper indexing, so both approaches execute efficiently on key columns.
  2. Execution Plan Cache: Varying ID list sizes can bloat the cache, but BatchFinder chunks IDs into standard sizes, so the DB can safely reuse cached plans.

3. Conclusions on Performance

Every prod setup is unique, but here is a practical rule of thumb for picking your strategy based on network layout:

  • If your app and DB share a fast local network (most likely your case in prod):
    • For complex structures and larger datasets, multiple small queries perform better and handle higher traffic.
    • For simple tables and smaller datasets, a single JOIN can be slightly faster, but only by a small margin.
    • The Takeaway: You can safely use Populator as your default when you are on a fast local network.
  • If your app and DB are far apart (high latency):
    • Single-query wins, because extra roundtrips get too expensive over a slow connection.
    • The Takeaway: Be cautious and measure your network lag before splitting queries.

Every prod system has its own limits. Under extreme traffic, running your own real-world benchmarks is the only way to be sure.

Two Ways You Can Use Populator in Your Application

One important clarification first: using Populator does not mean you must run exactly one query per table. Populator only stitches objects together. Your child finder can query a single table, join multiple tables, or call a remote API — Populator doesn't care.

1. Use Populator as a Local Problem Solver

Build your application the way you always do. Whenever you hit a one-to-many, multiple siblings, or cross-source assembly pain point, drop in Populator. It's a 3-line fix for an immediate problem.

2. Use Populator as an Application-Wide Architectural Strategy

Populator lets you create small, independent, reusable building blocks and assemble them on demand into any complex structure.

To adopt it as a core pattern:

  1. Create Reusable Building Blocks in the Repository Layer::
    • Data Models: Plain model / entity classes.
    • PopulateParams Creators: Centralized, shared creators that define the assembly relationships.
    • Data Access: Simple finders and remote API clients. Keep them small and focused.
  2. Orchestrate Assemblies inside the Service Layer: Use Populator to stitch the blocks together per request.
  3. Map to DTOs at the Very End: Convert the fully assembled structure to a specific DTO right before returning it to the client.

Blocks you create in early sprints are directly reused in later sprints, which cuts development time as the codebase grows.

This pattern lowers developer cognitive load, shortens development cycles, and makes long-term maintenance easier.

When AI coding assistants generate code with Populator, they don't produce spaghetti — they produce clean, isolated blocks that are easy to review, verify, and trust.

My GitHub repository includes a complete bookstore demo that showcases this architecture. Check the project layout to see how it keeps an enterprise application clean and scalable.

Useful Tips

Adapting finder parameter types

If the finder method was written by someone else and takes List<T> instead of Collection<T>, and you can't change it, adapt the Collection<T> parameter to the required type:

// Takes List<Integer>
List<Member> findMembersByTeamIds(List<Integer> teamIds)  { ... }

...
teams = Populator.of(teams)
    .populateMany(Team::getId, Member::getTeamId,
        // Adapt the type of teamIds
        teamIds -> findMembersByTeamIds(new ArrayList<>(teamIds)),
        Team::setMembers)
    .run();
Enter fullscreen mode Exit fullscreen mode

Type hinting for better IDE error detection

When calling Populator.populateOne(), Populator.populateMany(), PopulateParams.ofOne(), or PopulateParams.ofMany(), a typo in any of the 4 parameters will cause your IDE to underline the entire block, making it hard to see which parameter is wrong. Adding explicit type hints helps your IDE pinpoint the exact error. This is optional — you can remove it once the code compiles.

teams = Populator.of(teams)
    // Type hinting
    .<Team, Member, Integer>populateMany(Team::getId, Member::getTeamId,
        this::findMembersByTeamIds, Team::setMembers)
    .run();
Enter fullscreen mode Exit fullscreen mode

End of the Story

Another day, you run into your friend again.

Now you smile and say: "I got the answer. Use Populator."


Explore the Github Repository

Check out the project on GitHub:

banyan-in-breeze/populator-java

Inside you will find:

  • The Populator Source Code: Just 2 core files you can copy straight into your projects.
  • The Full README: All technical edge cases, including advanced scenarios we didn't cover here.
  • The simple Demo: Focused examples for individual techniques.
  • The bookstore Demo: A complete three-tier app showing the application-wide strategy in action.

Top comments (0)