Załóżmy że mamy dane jak poniżej. Dwie pierwsze kolumny to grupa, trzy kolejne kolumny dotyczą użytkownika, a ostatnie dwie zadania.
Grupa może posiadać wielu użytkowników. Użytkownik może posiadać wiele zadań.
Dane są spłaszczone i nie mamy na to wpływu.
Dane są dodatkowo posortowane po ID grupy, ID użytkownika, ID zadania.

        List<DataRow> rows = new ArrayList<>();
        //new DataRow([group id], [group name], [user id], [user name], [user surname], [task id], [task name])
        rows.add(new DataRow(1, "Group 1", 1, "Jon", "Snow", 1, "Task 1"));
        rows.add(new DataRow(1, "Group 1", 2, "Harry", "Potter", 2, "Task 2"));
        rows.add(new DataRow(1, "Group 1", 2, "Harry", "Potter", 3, "Task 3"));
        rows.add(new DataRow(2, "Group 2", 3, "Lara", "Croft", 4, "Task 4"));
        rows.add(new DataRow(2, "Group 2", 3, "Lara", "Croft", 5, "Task 5"));

Zastanawiam się jak najładniej i najlepiej przepisać dane wejściowe do takiej struktury (bez używania żadnych bibliotek):

public class GroupDto {

    private Integer id;
    private String name;
    private List<UserDto> users;
    ...
}
public class UserDto {

    private Integer id;
    private String name;
    private String surname;
    private List<TaskDto> tasks;
    ...
}
public class TaskDto {

    private Integer id;
    private String name;
    ...
}

Znam kilka sposobów na rozwiązanie tego, jednak nie mam pojęcia jaki sposób obecnie jest uważany za zgodny z zasadami pisania w Javie.

Rozwiązanie 1: chyba nie wygląda to najładniej?

        List<GroupDto> result = new ArrayList<>();
        GroupDto lastGroup = null;
        UserDto lastUser = null;
        for (DataRow row : rows) {

            if (lastGroup == null || !lastGroup.getId().equals(row.getGroupId())) {
                lastGroup = new GroupDto(row.getGroupId(), row.getGroupName());
                result.add(lastGroup);
            }

            if (lastUser == null || !lastUser.getId().equals(row.getUserId())) {
                lastUser = new UserDto(row.getUserId(), row.getUserName(), row.getUserSurname());
                lastGroup.getUsers().add(lastUser);
            }

            lastUser.getTasks().add(new TaskDto(row.getTaskId(), row.getTaskName()));
        }

Rozwiązanie 2: można pokusić się o użycie strumieni (wygląda ładniej). DTO powinny mieć zaimplementowane metody equals i hashCode.
Minus tego rozwiązania jest taki, że dla każdego wiersza danych wejściowych tworzymy trzy obiekty, a cześć z nich i tak jest odfiltrowana przy wkładaniu do mapy.

        Map<GroupDto, Map<UserDto, List<TaskDto>>> result = rows.stream().collect(
                groupingBy(row -> new GroupDto(row.getGroupId(), row.getGroupName()),
                        groupingBy(row -> new UserDto(row.getUserId(), row.getUserName(), row.getUserSurname()),
                                mapping(row -> new TaskDto(row.getTaskId(), row.getTaskName()), toList()))));

Przy tym rozwiązaniu pewnie można pokusić się o zostawienie wszystkiego w mapach lub połączenie wszystkiego tak:

        result .forEach((group, users) -> {
            group.getUsers().addAll(users.keySet());
            users.forEach((user, tasks) -> {
                user.getTasks().addAll(tasks);
            });
        });

        return result.keySet().stream().collect(toList());

Można to napisać jeszcze na inne sposoby, ale chyba już pokazałem o co mi mniej więcej chodzi.

Pytanie: jaki sposób jest najlepszy na przepisanie płaskich danych do przedstawionej struktury dto? Zależy mi na tym żeby było czytelne dla jak największej liczby osób i żeby nikt nie narzekał na jakość kodu :)