Need to turn your monstrously large Java Stream into a paginated List? If so, I've got you covered.

You can accomplish what you want with the help of a couple of friends. Their names are:

  • skip()
  • limit()

The whole thing is fairly easy to implement. It won't take too many lines of code at all.

Best of all, you can use the principles you learn about here to implement pagination in other programming languages. Then you'll be happy.

Okay, let's get this ball rolling.

The CRM App

Let's say you're building a CRM app. It does the normal stuff that CRM apps do: tracks activities between sales reps and contacts.

Those activities get stored in a MongoDB database as documents. Each document persists info like the title of the activity as well as the type, outcome, location, start time, end time, notes, and the contact involved.

As it stands right now, if you retrieve all the documents from the activities collection, the resulting data set looks like the JSON dump at this link.

As you can see, we're not messing around here. You're going to be working with real-world data.

On the Java side, the Activity class with its related classes mimic the data set that you see above. You can see examples of those classes over on GitHub.

So you can just do a findAll() on that collection above and get a List of Java objects that represent that JSON output. Then, you can use a Java Stream to filter, find, and map as you see fit.

That's what you'll do in this guide.

And, yes, you could do that kind of stuff with MongoDB aggregations. But you're not here to learn about aggregations are you?

There I Go... Turn the Page

Management approached you about your CRM app and asked you to give them a way to review all activities between sales reps and contacts. 

That's easy enough. You just need to grab all activities in descending order of start date and print out the relevant info on the UI.

But hold on. You don't want to put all the activities on a single screen. There could be thousands of them.

The answer here is obvious: pagination.

So you'll still grab all activities, but instead of showing them all at once you'll show them in chunks. Each chunk will display on a separate page.

Now that you understand the requirements, let's make it happen.

Just keep in mind: for this guide, you'll focus exclusively on back-end work. You're hear to learn about Java not CSS and TypeScript.

You Need More Info

All though the "main course" of the data you return to the client will include the Activity objects, you'll need more.

You'll need page-specific info as well. That means it's time for a new class.

Feel free to take a shortcut and make it an inner class. When you're done testing and want to roll it out, though, you'll probably want a separate class.

So here's what the Page class looks like:

private static class Page<T> {
    private Integer pageNumber;
    private Integer resultsPerPage;
    private Integer totalResults;
    private List<T> items;
    
    public Page(Integer pageNumber, Integer totalResults, List<T> items) {
        this.pageNumber = pageNumber;
        this.resultsPerPage = RESULTS_PER_PAGE;
        this.totalResults = totalResults;
        this.items = items;
    }
    
    public Integer getPageNumber() {
        return pageNumber;
    }
    public void setPageNumber(Integer pageNumber) {
        this.pageNumber = pageNumber;
    }
    public Integer getResultsPerPage() {
        return resultsPerPage;
    }
    public void setResultsPerPage(Integer resultsPerPage) {
        this.resultsPerPage = resultsPerPage;
    }
    public List<T> getItems() {
        return items;
    }
    public void setItems(List<T> items) {
        this.items = items;
    }
    public Integer getTotalResults() {
        return totalResults;
    }
    public void setTotalResults(Integer totalResults) {
        this.totalResults = totalResults;
    }
}

Nothing really fancy there. It's just a plain old Java object (POJO).

It's got four properties:

  • pageNumber - the current page in the set of pages
  • resultsPerPage - the number of results displayed on each page
  • totalResults - the total number of results in the complete set
  • items - the items to display on the current page

You might also want to throw in a convenience method for calculating the total number of pages. But feel free to keep things simple for now.

By the way, you might have noticed that Page uses type safety. That's indicated by the <T> you see right after the class name.

That means this class can be used with any type. It's not specific to the Activity class.

But once it's declared with that type, then any references in the items list must be of the same type. Otherwise, the compiler will complain.

The class includes a single constructor that takes in the current page number, total results, and items to display on the given page.

Note that resultsPerPage is initialized with a constant. That's because it won't change from page to page.

To keep things simple with this guide, just set it to 5.

private static final int RESULTS_PER_PAGE = 5;

That's easy enough.

Page Rage

Okay, now that you've got a Page class it's time to create a method that uses that class to return a Page object.

Here's what that method looks like:

private Page<Activity> getPage(List<Activity> activities, int pageNumber) {
    int skipCount = (pageNumber - 1) * RESULTS_PER_PAGE;
    
    List<Activity> activityPage = activities
            .stream()
            .skip(skipCount)
            .limit(RESULTS_PER_PAGE)
            .collect(Collectors.toList());
    
    Page<Activity> page = new Page<>(pageNumber, activities.size(), activityPage);
    
    return page;
}

That method accepts two parameters:

  • A List of all Activity objects retrieved from the MongoDB collection
  • The current page number to display

The method returns an instance of Page. Note the type safety, though: it's set to Activity here.

In other words, all the items in the list will be of the Activity type.

The skipCount variable calculates the number of objects in the list to skip.

Why would you want to skip any objects? Well, if you're displaying Page 2, then you'd skip over all the objects that you'd display on Page 1. 

See how that works?

So here's the formula: take the page number and subtract 1 from it. Then multiply that difference by the number of results per page.

If you want to display Page 1, for example, then the formula would evaluate to:

(1 - 1) * 5 = 0

Of course that evaluates to 0 because 1 - 1 is 0 and 0 multiplied by anything is still 0.

But that's what you want if you're on Page 1. You want the result to skip 0 objects because on Page 1 you shouldn't be skipping any objects.

On Page 2, though, you'd get this result:

(2 - 1) * 5  = 5

Once again, that's what you want. The algorithm should skip the first five objects (or the first page because there are five objects per page) so that it displays the results on the second page.

Okay, now take a look at this:

    List<Activity> activityPage = activities
            .stream()
            .skip(skipCount)
            .limit(RESULTS_PER_PAGE)
            .collect(Collectors.toList());

The first part of that method chain converts the List object to a Stream object. It does that with the stream() method.

And then it invokes the skip() method. That's where it skips over a number of objects in the list.

That number, of course, was calculated and assigned to skipCount as you saw above.

But even when you skip over the objects you'd display in the first one or more pages, you still don't want to display all the remaining objects. You only want to display the number of objects that belong on a single page.

That's why the limit() method exists here. It's going to limit the number of objects that end up in the final List.

Speaking of that final List, the last line in the method chain translates the Stream object to a List object. And everybody is happy.

But... Really?

Yeah. Really.

That's pretty much all you need to do. You can add some bells and whistles to handle more sophisticated pagination. But what you see above works for the purposes of this guide.

So test it out:

List<Activity> activities = activityRepo.findAllByOrderByStartDateDesc();

Page<Activity> page = getPage(activities, 1);

try {
    ObjectMapper objectMapper = new ObjectMapper();
    System.err.println(objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(page));
} catch (Exception e) {
    e.printStackTrace();
}

The first line of that code grabs all activities from the MongoDB collection and sorts them by start date in descending order.

The second line gets the Page object using the method from the previous section. In this case, it's getting the first page in the set because the second parameter is set to 1.

The try/catch block spits out the results in JSON format using bright red colors.

So if you run that code above with the dataset I referenced earlier, you'll get this result:

{
  "pageNumber" : 1,
  "resultsPerPage" : 5,
  "totalResults" : 32,
  "items" : [ {
    "id" : "601f2a162a913d1b4e94fef9",
    "type" : {
      "id" : "6016a960ca8c08019b4dfcbe",
      "name" : "Appointment",
      "icon" : "calendar_today"
    },
    "title" : "Lunch",
    "outcome" : null,
    "notes" : null,
    "location" : "Bruno's",
    "startDate" : 1612891800000,
    "endDate" : 1612895400000,
    "contact" : {
      "id" : "6014199147692f2a4194ff95",
      "firstName" : "Lucy",
      "lastName" : "Cheng",
      "account" : {
        "id" : "60141483b56f731fe77e902e",
        "name" : "Queen Inc."
      },
      "salesOwner" : {
        "id" : "6014081e221e1b534a8aa432",
        "firstName" : "Milton",
        "lastName" : "Jones",
        "username" : "milton"
      }
    }
  }, {
    "id" : "601fe3c68fc6df7eddcf3af0",
    "type" : {
      "id" : "6016a960ca8c08019b4dfcbd",
      "name" : "Chat",
      "icon" : "chat"
    },
    "title" : "Seems curious",
    "outcome" : null,
    "notes" : "Chatted via Skype. Seems interested in our products but no purchasing authority.",
    "location" : null,
    "startDate" : 1612702800000,
    "endDate" : null,
    "contact" : {
      "id" : "6014199147692f2a4194ff9c",
      "firstName" : "Bert",
      "lastName" : "Simmz",
      "account" : {
        "id" : "60141483b56f731fe77e9035",
        "name" : "No Moon"
      },
      "salesOwner" : {
        "id" : "6014081e221e1b534a8aa432",
        "firstName" : "Milton",
        "lastName" : "Jones",
        "username" : "milton"
      }
    }
  }, {
    "id" : "601e8819b2e66d12ef1dc4ef",
    "type" : {
      "id" : "6016a960ca8c08019b4dfcbc",
      "name" : "Phone Call",
      "icon" : "phone"
    },
    "title" : "Another phone call",
    "outcome" : null,
    "notes" : "Looks promising. Really.",
    "location" : null,
    "startDate" : 1612527300000,
    "endDate" : null,
    "contact" : {
      "id" : "6014199147692f2a4194ff9d",
      "firstName" : "Governor",
      "lastName" : "Rover",
      "account" : {
        "id" : "60141482b56f731fe77e902d",
        "name" : "Working for Han"
      },
      "salesOwner" : {
        "id" : "6014081e221e1b534a8aa432",
        "firstName" : "Milton",
        "lastName" : "Jones",
        "username" : "milton"
      }
    }
  }, {
    "id" : "601fe6f38fc6df7eddcf3af9",
    "type" : {
      "id" : "6016a960ca8c08019b4dfcbc",
      "name" : "Phone Call",
      "icon" : "phone"
    },
    "title" : "Went with another company",
    "outcome" : {
      "id" : "6016a408ae0e817a115fd06b",
      "name" : "Not Interested"
    },
    "notes" : "Decided to go with another company for services. Will follow up in several months.",
    "location" : null,
    "startDate" : 1612358100000,
    "endDate" : null,
    "contact" : {
      "id" : "6014199147692f2a4194ff96",
      "firstName" : "Mercy",
      "lastName" : "Windsor",
      "account" : {
        "id" : "60141483b56f731fe77e902f",
        "name" : "Cloud City"
      },
      "salesOwner" : {
        "id" : "6014081e221e1b534a8aa432",
        "firstName" : "Milton",
        "lastName" : "Jones",
        "username" : "milton"
      }
    }
  }, {
    "id" : "601fe5ee8fc6df7eddcf3af5",
    "type" : {
      "id" : "6016a64c33ffe20d90fa8232",
      "name" : "Email",
      "icon" : "email"
    },
    "title" : "Email followup",
    "outcome" : {
      "id" : "6016a407ae0e817a115fd069",
      "name" : "Did Not Respond"
    },
    "notes" : "Followup email after lunch appointment.",
    "location" : null,
    "startDate" : 1612357200000,
    "endDate" : null,
    "contact" : {
      "id" : "6014199147692f2a4194ff97",
      "firstName" : "Blinky",
      "lastName" : "Scene",
      "account" : {
        "id" : "60141483b56f731fe77e9030",
        "name" : "For Luke"
      },
      "salesOwner" : {
        "id" : "6014081e221e1b534a8aa432",
        "firstName" : "Milton",
        "lastName" : "Jones",
        "username" : "milton"
      }
    }
  } ]
}

And that's exactly what you'd expect because it's the top five results sorted by descending order according to start date.

Now change that second parameter in the getPage() method from 1 to 2 and you'll get this:

{
  "pageNumber" : 2,
  "resultsPerPage" : 5,
  "totalResults" : 32,
  "items" : [ {
    "id" : "601fe3848fc6df7eddcf3aef",
    "type" : {
      "id" : "6016a960ca8c08019b4dfcbe",
      "name" : "Appointment",
      "icon" : "calendar_today"
    },
    "title" : "Lunch",
    "outcome" : {
      "id" : "6016a408ae0e817a115fd06a",
      "name" : "Interested"
    },
    "notes" : "Good conversation. Ready to pull the trigger.",
    "location" : "Izzy's",
    "startDate" : 1612281600000,
    "endDate" : 1612285200000,
    "contact" : {
      "id" : "6014199147692f2a4194ff99",
      "firstName" : "Opus",
      "lastName" : "Mei",
      "account" : {
        "id" : "60141483b56f731fe77e9032",
        "name" : "Sandz"
      },
      "salesOwner" : {
        "id" : "6014081e221e1b534a8aa432",
        "firstName" : "Milton",
        "lastName" : "Jones",
        "username" : "milton"
      }
    }
  }, {
    "id" : "601fe3fc8fc6df7eddcf3af1",
    "type" : {
      "id" : "6016a64c33ffe20d90fa8232",
      "name" : "Email",
      "icon" : "email"
    },
    "title" : "Emailed me",
    "outcome" : null,
    "notes" : "Emailed me to ask more about our services. Sent the boiler plate reply and asked for info about someone with purchasing authority.",
    "location" : null,
    "startDate" : 1612270800000,
    "endDate" : null,
    "contact" : {
      "id" : "6014199147692f2a4194ff9c",
      "firstName" : "Bert",
      "lastName" : "Simmz",
      "account" : {
        "id" : "60141483b56f731fe77e9035",
        "name" : "No Moon"
      },
      "salesOwner" : {
        "id" : "6014081e221e1b534a8aa432",
        "firstName" : "Milton",
        "lastName" : "Jones",
        "username" : "milton"
      }
    }
  }, {
    "id" : "601fe4f98fc6df7eddcf3af4",
    "type" : {
      "id" : "6016a960ca8c08019b4dfcbe",
      "name" : "Appointment",
      "icon" : "calendar_today"
    },
    "title" : "Lunch",
    "outcome" : {
      "id" : "6016a408ae0e817a115fd06a",
      "name" : "Interested"
    },
    "notes" : "Talked more about Java Enterprise and the work we do. Gave highlights of some of the solutions we've delivered in the past.",
    "location" : "Loony's",
    "startDate" : 1611939600000,
    "endDate" : 1611943200000,
    "contact" : {
      "id" : "6014199147692f2a4194ff97",
      "firstName" : "Blinky",
      "lastName" : "Scene",
      "account" : {
        "id" : "60141483b56f731fe77e9030",
        "name" : "For Luke"
      },
      "salesOwner" : {
        "id" : "6014081e221e1b534a8aa432",
        "firstName" : "Milton",
        "lastName" : "Jones",
        "username" : "milton"
      }
    }
  }, {
    "id" : "601fe6aa8fc6df7eddcf3af8",
    "type" : {
      "id" : "6016a960ca8c08019b4dfcbe",
      "name" : "Appointment",
      "icon" : "calendar_today"
    },
    "title" : "Lunch",
    "outcome" : {
      "id" : "6016a408ae0e817a115fd06a",
      "name" : "Interested"
    },
    "notes" : null,
    "location" : "Izzy's",
    "startDate" : 1611854100000,
    "endDate" : 1611857700000,
    "contact" : {
      "id" : "6014199147692f2a4194ff96",
      "firstName" : "Mercy",
      "lastName" : "Windsor",
      "account" : {
        "id" : "60141483b56f731fe77e902f",
        "name" : "Cloud City"
      },
      "salesOwner" : {
        "id" : "6014081e221e1b534a8aa432",
        "firstName" : "Milton",
        "lastName" : "Jones",
        "username" : "milton"
      }
    }
  }, {
    "id" : "601fe0718fc6df7eddcf3ae7",
    "type" : {
      "id" : "6016a960ca8c08019b4dfcbe",
      "name" : "Appointment",
      "icon" : "calendar_today"
    },
    "title" : "Lunch",
    "outcome" : null,
    "notes" : "Good conversation about her staffing issues and requirements for her clients.",
    "location" : "Banana Joe's",
    "startDate" : 1611852300000,
    "endDate" : 1611855900000,
    "contact" : {
      "id" : "6014199147692f2a4194ff9a",
      "firstName" : "Yeezu",
      "lastName" : "Joy",
      "account" : {
        "id" : "60141483b56f731fe77e9031",
        "name" : "Empire"
      },
      "salesOwner" : {
        "id" : "6014081e221e1b534a8aa432",
        "firstName" : "Milton",
        "lastName" : "Jones",
        "username" : "milton"
      }
    }
  } ]
}

And, once again, that's a perfect match. It's literally Page 2 of the data if you break it all down into five-item pages.

Finally, check out the last page by changing the second parameter in getPage() to 7. You should see this:

{
  "pageNumber" : 7,
  "resultsPerPage" : 5,
  "totalResults" : 32,
  "items" : [ {
    "id" : "6016b316f9722c71f96cddd6",
    "type" : {
      "id" : "6016a960ca8c08019b4dfcc0",
      "name" : "Web Form Completion",
      "icon" : "list_alt"
    },
    "title" : "Completed More Info web form on careydevelopment.us",
    "outcome" : null,
    "notes" : null,
    "location" : null,
    "startDate" : 1610305380000,
    "endDate" : 1610308980000,
    "contact" : {
      "id" : "6014199147692f2a4194ff9d",
      "firstName" : "Governor",
      "lastName" : "Rover",
      "account" : {
        "id" : "60141482b56f731fe77e902d",
        "name" : "No Moon"
      },
      "salesOwner" : {
        "id" : "6014081e221e1b534a8aa432",
        "firstName" : "Milton",
        "lastName" : "Jones",
        "username" : "milton"
      }
    }
  }, {
    "id" : "601fd76fda249c7531fd28f3",
    "type" : {
      "id" : "6016a64c33ffe20d90fa8232",
      "name" : "Email",
      "icon" : "email"
    },
    "title" : "Emailed me asking about REST development",
    "outcome" : {
      "id" : "6016a408ae0e817a115fd06c",
      "name" : "Appointment Scheduled"
    },
    "notes" : "She's looking to a legacy project into a solution with REST and MongoDB.",
    "location" : null,
    "startDate" : 1609855200000,
    "endDate" : null,
    "contact" : {
      "id" : "6014199147692f2a4194ff9a",
      "firstName" : "Yeezu",
      "lastName" : "Joy",
      "account" : {
        "id" : "60141483b56f731fe77e9031",
        "name" : "Empire"
      },
      "salesOwner" : {
        "id" : "6014081e221e1b534a8aa432",
        "firstName" : "Milton",
        "lastName" : "Jones",
        "username" : "milton"
      }
    }
  } ]
}

Only two results?

Yep. And that's okay here because it should have only two results.

It's the last page. And the last page doesn't have five results because there are only 32 objects in the whole set.

So the last page here gets those last two object after the 30th object.

In other words, it's all working according to plan.

Wrapping It Up

That's it. Now you know how to use Java Streams to do some pagination.

Why not take what you've learned in this guide to the next level? Add a UI that goes along with the algorithm that works here.

And feel free to make it your own. Use different-sized pages. Or let people customize their own page sizes.

But whatever you do, make sure you have fun!

Photo by Wendy van Zyl from Pexels