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 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
- Introducing Populator
- Handle Multi-Sibling, Multi-Level Structure
- Use PopulateParams for Cleaner, Reusable Code
- Use ChildModifier for Ultimate Reusability
- Full Implementation of the Team Structure
- Reusing the Building Blocks
- Populator's Concurrent Capability
- Data Model Classes: Mutable, Immutable, and Semi-mutable
- Find Data by IDs in Batches: BatchFinder
- Performance Analysis
- Two Ways You Can Use Populator in Your Application
- Useful Tips
- End of the Story
- Explore the Github Repository
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 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; } 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(); What this does:
- Creates a
Populatorinstance for teams - Calls
populateMany()to describe how to populate them. It takes four arguments:-
Team::getIdandMember::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(); 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) 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 } 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) { ... } 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 } 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(); 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(); } 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); } 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); } 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); } 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(); To skip child population:
teams = Populator.of(teams) .populate(paramsTeamWithMembers(ChildModifier.none())) .run(); 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 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 { ... } 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) {...} 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() {...} 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(); } The advantages are clear:
- Big, complex problems become small building blocks — models, finders, and
PopulateParamscreators. - 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(); 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(); 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 Source:https://dev.to/banyan-in-breeze/a-small-java-util-solve-complex-orm-pain-build-a-clean-system-4f8p