Index

Stream Composition and Pipelines

Java Streams

13.1 Chaining Multiple Streams

One of the most powerful features of Java Streams is the ability to chain multiple operations together, forming a clear and concise data processing pipeline. Each intermediate operation returns a new stream, enabling fluent composition of transformations without modifying the original data source.

Immutability and Laziness in Stream Chaining

Practical Examples

Here are two real-world examples illustrating chaining of multiple stream operations:

Example 1: Filter, Map, and Sort a List of Products

Imagine a product list where you want to find all available items priced above $20, convert their names to uppercase, and then sort alphabetically.

import java.util.*;
import java.util.stream.*;

public class ProductPipeline {
    static class Product {
        String name;
        double price;
        boolean available;

        Product(String name, double price, boolean available) {
            this.name = name; this.price = price; this.available = available;
        }

        @Override public String toString() {
            return name + " ($" + price + ")";
        }
    }

    public static void main(String[] args) {
        List<Product> products = List.of(
            new Product("Laptop", 999.99, true),
            new Product("Mouse", 19.99, true),
            new Product("Keyboard", 29.99, false),
            new Product("Monitor", 199.99, true),
            new Product("USB Cable", 10.99, true)
        );

        List<String> result = products.stream()
            .filter(p -> p.available)                  // Keep only available
            .filter(p -> p.price > 20)                 // Price > 20
            .map(p -> p.name.toUpperCase())            // Map to uppercase names
            .sorted()                                  // Sort alphabetically
            .collect(Collectors.toList());

        System.out.println(result);
    }
}

Output:

[LAPTOP, MONITOR]

Example 2: FlatMapping Nested Lists and Sorting

Suppose you have a list of orders, each containing multiple items. You want to extract all unique item names, filter those starting with "B", and sort them.

import java.util.*;
import java.util.stream.*;

public class OrderPipeline {
    static class Order {
        List<String> items;

        Order(List<String> items) {
            this.items = items;
        }
    }

    public static void main(String[] args) {
        List<Order> orders = List.of(
            new Order(List.of("Banana", "Apple", "Bread")),
            new Order(List.of("Butter", "Orange", "Banana")),
            new Order(List.of("Bread", "Blueberry", "Apple"))
        );

        List<String> filteredItems = orders.stream()
            .flatMap(order -> order.items.stream())   // Flatten all items into one stream
            .filter(item -> item.startsWith("B"))     // Items starting with 'B'
            .distinct()                               // Remove duplicates
            .sorted()                                 // Sort alphabetically
            .collect(Collectors.toList());

        System.out.println(filteredItems);
    }
}

Output:

[Banana, Blueberry, Bread, Butter]

Best Practices for Stream Chaining

Summary

Chaining multiple stream operations lets you build clear, modular, and efficient data processing pipelines. Immutability and laziness ensure safe, optimized execution. By combining operations like filter(), map(), flatMap(), and sorted(), you can express complex logic in a fluent, readable style that’s easy to maintain and understand.

Index

13.2 Combining Streams with concat()

The Stream.concat() method allows you to combine two separate streams into a single stream. This is useful when you have multiple data sources or intermediate streams you want to merge seamlessly and then continue processing as one.

How Stream.concat() Works

Limitations

Examples

Example 1: Concatenating Numeric Streams

Combine two IntStreams of numbers and sum all elements.

import java.util.stream.*;

public class NumericConcatExample {
    public static void main(String[] args) {
        IntStream first = IntStream.range(1, 4);   // 1, 2, 3
        IntStream second = IntStream.range(4, 7);  // 4, 5, 6

        IntStream combined = IntStream.concat(first, second);

        int sum = combined.sum();  // 1+2+3+4+5+6 = 21
        System.out.println("Sum of combined streams: " + sum);
    }
}

Expected Output:

Sum of combined streams: 21

Example 2: Concatenating Streams from Lists

Merge two lists of strings into one stream, convert all to uppercase, and collect.

import java.util.*;
import java.util.stream.*;

public class ListConcatExample {
    public static void main(String[] args) {
        List<String> list1 = List.of("apple", "banana");
        List<String> list2 = List.of("cherry", "date");

        Stream<String> combined = Stream.concat(list1.stream(), list2.stream());

        List<String> result = combined
            .map(String::toUpperCase)
            .collect(Collectors.toList());

        System.out.println(result);
    }
}

Expected Output:

[APPLE, BANANA, CHERRY, DATE]

Example 3: Concatenating Streams of Custom Objects

Suppose you have two employee streams and want to concatenate them, then filter by department.

import java.util.*;
import java.util.stream.*;

public class EmployeeConcatExample {
    static class Employee {
        String name;
        String department;

        Employee(String name, String department) {
            this.name = name; this.department = department;
        }

        @Override
        public String toString() {
            return name + " (" + department + ")";
        }
    }

    public static void main(String[] args) {
        Stream<Employee> teamA = Stream.of(
            new Employee("Alice", "Sales"),
            new Employee("Bob", "HR")
        );

        Stream<Employee> teamB = Stream.of(
            new Employee("Carol", "Sales"),
            new Employee("Dave", "IT")
        );

        List<Employee> salesTeam = Stream.concat(teamA, teamB)
            .filter(e -> "Sales".equals(e.department))
            .collect(Collectors.toList());

        System.out.println("Sales team: " + salesTeam);
    }
}

Expected Output:

Sales team: [Alice (Sales), Carol (Sales)]

Summary

By mastering Stream.concat(), you can flexibly merge data sources and build more expressive stream pipelines.

Index

13.3 Stream Builders and Custom Stream Sources

Java Streams offer flexible ways to create streams programmatically beyond collections and arrays. This includes Stream.builder(), Stream.generate(), Stream.iterate(), and custom sources for dynamic or infinite data. Each method serves different needs, such as deferred computation, on-demand data, or constructing values programmatically.

Using Stream.builder()

Stream.builder() allows you to build a stream manually by adding elements one at a time. It’s useful when the data isn’t already in a collection or needs to be constructed conditionally.

import java.util.stream.*;

public class BuilderExample {
    public static void main(String[] args) {
        Stream<String> names = Stream.<String>builder()
            .add("Alice")
            .add("Bob")
            .add("Carol")
            .build();

        names.forEach(System.out::println);
    }
}

Using Stream.generate()

Stream.generate() creates an infinite stream where each element is supplied by a Supplier<T>. It’s ideal for producing repeated or computed values, such as random numbers.

import java.util.stream.*;
import java.util.Random;

public class GenerateExample {
    public static void main(String[] args) {
        Random rand = new Random();

        Stream.generate(() -> rand.nextInt(100))  // infinite stream of random numbers
              .limit(5)
              .forEach(System.out::println);
    }
}

Use limit() to safely cap infinite streams.

Using Stream.iterate()

Stream.iterate() is used to produce elements by applying a function repeatedly, often for sequences.

import java.util.stream.*;

public class IterateExample {
    public static void main(String[] args) {
        Stream.iterate(1, n -> n + 2)   // odd numbers
              .limit(5)
              .forEach(System.out::println);
    }
}

From Java 9 onward, you can also pass a predicate to create bounded iterate() streams.

Creating Custom Stream Sources

Custom streams are useful when reading from non-standard sources like sensors, logs, or APIs. This often involves wrapping a custom Iterator or using Spliterator and StreamSupport.

Example: Stream from a Custom Iterator (simulated sensor data)

import java.util.*;
import java.util.stream.*;

public class CustomIteratorExample {
    public static void main(String[] args) {
        Iterator<Double> sensorSimulator = new Iterator<>() {
            private int count = 0;

            @Override
            public boolean hasNext() {
                return count++ < 5; // Simulate 5 sensor readings
            }

            @Override
            public Double next() {
                return 20 + Math.random() * 10; // Random temperature between 20–30°C
            }
        };

        Iterable<Double> iterable = () -> sensorSimulator;

        Stream<Double> sensorStream = StreamSupport.stream(iterable.spliterator(), false);

        sensorStream.forEach(temp -> System.out.println("Sensor: " + temp + "°C"));
    }
}

When to Use Each Approach

Method Best Use Case
Stream.builder() Dynamically building small or condition-based streams
Stream.generate() Infinite or lazy values like UUIDs, timestamps, sensors
Stream.iterate() Sequences with clear progression rules
Custom Iterator External/dynamic data like sensors, APIs, logs

Summary

Java provides several flexible ways to build streams beyond collections:

These tools unlock the full power of streams for modeling complex and evolving data sources.

Index