Skip to content
CMO & CTO
CMO & CTO

Closing the Bridge Between Marketing and Technology, By Luis Fernandez

  • Digital Experience
    • Experience Strategy
    • Experience-Driven Commerce
    • Multi-Channel Experience
    • Personalization & Targeting
    • SEO & Performance
    • User Journey & Behavior
  • Marketing Technologies
    • Analytics & Measurement
    • Content Management Systems
    • Customer Data Platforms
    • Digital Asset Management
    • Marketing Automation
    • MarTech Stack & Strategy
    • Technology Buying & ROI
  • Software Engineering
    • Software Engineering
    • Software Architecture
    • General Software
    • Development Practices
    • Productivity & Workflow
    • Code
    • Engineering Management
    • Business of Software
    • Code
    • Digital Transformation
    • Systems Thinking
    • Technical Implementation
  • About
CMO & CTO

Closing the Bridge Between Marketing and Technology, By Luis Fernandez

Working with Java Streams in Real Projects

Posted on February 21, 2015 By Luis Fernandez

Java 8 finally landed on real servers and the Stream API is now part of my daily routine, for good and for weird.

I keep meeting teams that tried Java Streams in small scripts and then moved back to loops when the code hit production. The usual reasons are fear of magic, lost stack traces, or someone reading a tutorial that went from zero to cryptic in two minutes. Once you drop the fancy and stick to a few patterns, streams shine. The short version is this. A stream is just a pipeline that transforms data. You build a chain of filter, map, maybe a flatMap, and then a terminal operation like collect or reduce. The pipe either gives you a value, a collection, or it does something like forEach. If your team names things well and resists the urge to squeeze everything into one line, your future self will thank you.

Let me start with the one that shows up everywhere. Read only transform of a list into another list. This is the baseline for readability and the pitch I use when someone says streams are hard to read. It mirrors the wire from left to right. You can scan it the way you read text. No shared state. No counters floating around.

// From active users to their emails, in order
List<String> emails = users.stream()
    .filter(User::isActive)
    .map(User::getEmail)
    .collect(Collectors.toList());

That reads like a sentence. Start with users. Keep active ones. Turn them into emails. Collect to a list. When someone tries to cram conditions and transformations into one giant lambda, stop them. Split steps so each verb is obvious. It pays off when you add a new rule. You can drop a .peek during a debug session too, which is a neat trick if a stage mangles data and you want to look without side effects.

// Quick debug without changing structure
List<String> emails = users.stream()
    .filter(User::isActive)
    .peek(u -> logger.debug("Active user " + u.getId()))
    .map(User::getEmail)
    .collect(Collectors.toList());

Once you get comfy, the next thing that makes teams smile is Collectors. The standard ones cover many use cases and keep code short without being vague. Counting is straight. Grouping is a gem. The nice part is that you can mix them. I keep a small mental cheat sheet for groupingBy, mapping, counting, joining, and a sum. That gets you through a lot of reports and back office screens.

// How many orders per city
Map<String, Long> ordersPerCity = orders.stream()
    .collect(Collectors.groupingBy(Order::getCity, Collectors.counting()));

// List of product names per customer
Map<Customer, List<String>> productsByCustomer = orders.stream()
    .collect(Collectors.groupingBy(Order::getCustomer,
        Collectors.mapping(o -> o.getProduct().getName(), Collectors.toList())));

// Total revenue
BigDecimal totalRevenue = orders.stream()
    .map(Order::getTotal)
    .reduce(BigDecimal.ZERO, BigDecimal::add);

Note that reduce with an identity keeps you away from optionals in the final step. When you need a custom collector, prefer composing the built in ones first. Only write Collector.of when you have to. That is not about purism. It is about keeping your team inside the same set of tools so the next reader guesses the shape before they even open the file.

Another day saver is flatMap. Real data is nested. You have a list of posts and each post has a list of tags. You want a set of all tags. Without streams you write two loops and a pile of checks for null. With streams you turn a stream of lists into a stream of values and then treat it like any other stream. Spark joy by collecting to a LinkedHashSet to keep order and remove duplicates in one go.

// All unique tags, in insertion order
Set<String> allTags = posts.stream()
    .map(Post::getTags) // List<String> or maybe null
    .filter(Objects::nonNull)
    .flatMap(List::stream)
    .map(String::trim)
    .filter(s -> !s.isEmpty())
    .collect(Collectors.toCollection(LinkedHashSet::new));

Null shows up. It always does. Java 8 gave us Optional but the stream library does not have a built in way to turn a nullable value into a one item stream. That comes later. For now the simplest path is to filter null early or wrap the nullable field at the edges. Keep it boring and you dodge surprises in the middle of a pipeline.

// Find the owner of an account by id, skip null owners
Optional<User> owner = accounts.stream()
    .filter(a -> a.getId().equals(targetId))
    .map(Account::getOwner) // could be null
    .filter(Objects::nonNull)
    .findFirst();

Now the spicy part everyone asks in code reviews. Parallel streams. They look tempting. Add a keyword and boom the computer works harder. My rule in real projects is very simple. Use parallel only for pure CPU work where each element is heavy and independent. Parsing large numbers, crunching images, big math. If you touch IO, stay on a normal stream. The fork join common pool is shared. A slow call to a database will block threads you did not plan to block. If you really need parallel on IO, manage your own pool or use a library built for that style.

// Good fit: CPU heavy calculation per element
double score = documents.parallelStream()
    .mapToDouble(this::rankDocument) // pure calculation, no IO, no shared state
    .sum();

// Watch out: shared mutable state in map will bite you
AtomicInteger badCounter = new AtomicInteger(0);
long count = items.parallelStream()
    .map(i -> { if (looksBad(i)) badCounter.incrementAndGet(); return i; })
    .filter(this::stillGood)
    .count(); // Resist this pattern, keep state out of the stream

On the topic of shared state, the best thing about stream code is that it pushes you to keep side effects on the edges. If your map writes to a list or updates a field, you just made the code hard to test and hard to change. A stream with side effects is like a quiet loop with hidden spikes. Keep mutations in a single place after the pipeline, not inside it.

Teams also ask about debugging. Two ideas work well. One is peek for quick looks. The other is to give names to your lambdas. Pull out logic into methods with names that read well. filter(this::isEligible) beats a long lambda with three conditions. Your IDE can step into those methods and show a familiar stack. IntelliJ and Eclipse are already pretty friendly with lambdas and method references. NetBeans too. Step into a small method, not into a sea of inline conditions. That keeps your focus sharp when a test is red five minutes before you go home.

// Named methods make pipelines readable and debuggable
List<Invoice> billable = invoices.stream()
    .filter(this::isPaid)
    .filter(this::withinThisQuarter)
    .sorted(Comparator.comparing(Invoice::getDate))
    .collect(Collectors.toList());

private boolean isPaid(Invoice i) { return i.isPaid(); }
private boolean withinThisQuarter(Invoice i) {
    LocalDate date = i.getDate();
    return !date.isBefore(quarterStart) && !date.isAfter(quarterEnd);
}

What about performance. The honest answer is that a well written stream and a clean for loop are usually close. The big wins come from better algorithms, not from the syntax. Streams make it easy to push work into a single pass with a collector or a reduction. You avoid creating extra lists and maps all over the place. That cuts pressure on the garbage collector. When a pipeline gets slow, profile before you change style. I have switched pipelines back to loops on hot paths and I have also replaced three nested loops with one stream that uses groupingBy with a downstream collector. Both moves were wins, and both moves came after a measurement, not a hunch.

There is a small social trick that helped my team adopt Java 8 without drama. Agree on a few pipeline shapes and repeat them. A basic select and transform like the emails example. A collect with grouping. A flatMap for nested lists. A reduction with a clear identity. People learn patterns, not features. Once the patterns live in your code base, new folks recognize them and get productive faster. The code review talks shift from syntax to rules. That is where you want the energy.

If you are moving an old module to streams, do it in small steps. Start at the borders of your app where objects enter and leave. Replace mapper classes and formatters with small pipelines. Keep your hot queries on a pipeline that returns a result and let the rest of the module continue as it was. This avoids the giant flag day where everything changes and the team spends a week chasing regressions. Java 8 lets you write the new style while the old style still works. Use that to your advantage.

Finally, here is a quick checklist I keep nearby when I write stream API code in a real project.

  • Each stage should do one clear thing. If it does two, split it.
  • Prefer method references when the method name tells the story. Use lambdas when you need small glue.
  • Keep side effects out of the pipeline. If state must change, change it after the terminal step.
  • Group with built in Collectors before writing your own collector.
  • Parallel only for pure CPU work with heavy items and no shared state.
  • Measure when speed matters. Decide from data, not from taste.
  • Use peek only for quick debug sessions, then delete it.
  • Filter nulls early. Do not let them travel deep into the chain.

Side bonus: your code reviews get shorter when pipelines follow the same shape. People stop asking what a line does and start asking if the rule is right.

// A slightly richer example that mixes several ideas
Map<Customer, BigDecimal> topRevenueByCustomer = orders.stream()
    .filter(Order::isPaid)                              // rule
    .filter(o -> o.getDate().isAfter(monthStart))       // rule
    .collect(Collectors.groupingBy(Order::getCustomer,  // shape
        Collectors.mapping(Order::getTotal,             // downstream map
            Collectors.reducing(BigDecimal.ZERO, BigDecimal::add)))) // sum
    )
    .entrySet().stream()
    .sorted(Map.Entry.<Customer, BigDecimal>comparingByValue().reversed())
    .limit(10)
    .collect(Collectors.toMap(
        Map.Entry::getKey,
        Map.Entry::getValue,
        (a, b) -> a,
        LinkedHashMap::new));

We are all juggling plenty right now with new toys showing up every week, from Docker builds to fresh releases of our favorite IDEs. The nice thing about Streams is that you can adopt them today in production code without turning your app inside out. Start small, pick a few patterns, keep the pipe honest, and your code becomes easier to read and reason about.

Keep it simple, keep it pure, keep shipping.

Code Development Practices Software Engineering coding-practicesjavaspring

Post navigation

Previous post
Next post
  • Digital Experience (94)
    • Experience Strategy (19)
    • Experience-Driven Commerce (5)
    • Multi-Channel Experience (9)
    • Personalization & Targeting (21)
    • SEO & Performance (10)
  • Marketing Technologies (92)
    • Analytics & Measurement (14)
    • Content Management Systems (45)
    • Customer Data Platforms (4)
    • Digital Asset Management (8)
    • Marketing Automation (6)
    • MarTech Stack & Strategy (10)
    • Technology Buying & ROI (3)
  • Software Engineering (310)
    • Business of Software (20)
    • Code (30)
    • Development Practices (52)
    • Digital Transformation (21)
    • Engineering Management (25)
    • General Software (82)
    • Productivity & Workflow (30)
    • Software Architecture (85)
    • Technical Implementation (23)
  • 2025 (12)
  • 2024 (8)
  • 2023 (18)
  • 2022 (13)
  • 2021 (3)
  • 2020 (8)
  • 2019 (8)
  • 2018 (23)
  • 2017 (17)
  • 2016 (40)
  • 2015 (37)
  • 2014 (25)
  • 2013 (28)
  • 2012 (24)
  • 2011 (30)
  • 2010 (42)
  • 2009 (25)
  • 2008 (13)
  • 2007 (33)
  • 2006 (26)

Ab Testing Adobe Adobe Analytics Adobe Target AEM agile-methodologies Analytics architecture-patterns CDP CMS coding-practices content-marketing Content Supply Chain Conversion Optimization Core Web Vitals customer-education Customer Data Platform Customer Experience Customer Journey DAM Data Layer Data Unification documentation DXP Individualization java Martech metrics mobile-development Mobile First Multichannel Omnichannel Personalization product-strategy project-management Responsive Design Search Engine Optimization Segmentation seo spring Targeting Tracking user-experience User Journey web-development

©2025 CMO & CTO | WordPress Theme by SuperbThemes