Functional Programming Patterns in Java
Java has embraced functional programming since Java 8 with lambdas, streams, and the java.util.function package. Beyond basic stream operations, you can apply powerful functional patterns (composition, currying, monadic error handling, and lazy evaluation) to write code that is more concise, testable, and less prone to side effects.
Contents
Function Composition
Function composition is the cornerstone of functional programming, combining small, focused functions into larger transformations. Java's Function<T,R>andThen()compose()andThen()compose()Predicateand()or()negate()
import java.util.function.*;
import java.util.List;
import java.util.stream.Collectors;
// Function.andThen() — apply this function first, then the other
Function<String, String> trim = String::trim;
Function<String, String> toLowerCase = String::toLowerCase;
Function<String, String> removeSpaces = s -> s.replaceAll("\\s+", "-");
// Chain: trim -> toLowerCase -> removeSpaces
Function<String, String> slugify = trim
.andThen(toLowerCase)
.andThen(removeSpaces);
System.out.println(slugify.apply(" Hello World "));
// "hello-world"
// Function.compose() — apply the argument function FIRST, then this function
Function<Integer, Integer> doubleIt = x -> x * 2;
Function<Integer, Integer> addThree = x -> x + 3;
// compose: addThree runs first, then doubleIt
Function<Integer, Integer> addThenDouble = doubleIt.compose(addThree);
System.out.println(addThenDouble.apply(5)); // (5 + 3) * 2 = 16
// andThen: doubleIt runs first, then addThree
Function<Integer, Integer> doubleThenAdd = doubleIt.andThen(addThree);
System.out.println(doubleThenAdd.apply(5)); // (5 * 2) + 3 = 13
// Predicate composition with and(), or(), negate()
Predicate<String> isNotEmpty = s -> !s.isEmpty();
Predicate<String> startsWithA = s -> s.startsWith("A");
Predicate<String> endsWithZ = s -> s.endsWith("z");
Predicate<String> complexFilter = isNotEmpty
.and(startsWithA)
.or(endsWithZ);
List<String> words = List.of("Apple", "buzz", "Aztec", "", "jazz", "Apricot");
List<String> filtered = words.stream()
.filter(complexFilter)
.toList();
System.out.println(filtered); // [Apple, buzz, Aztec, jazz, Apricot]
// Negating predicates
Predicate<Integer> isEven = n -> n % 2 == 0;
Predicate<Integer> isOdd = isEven.negate();
List<Integer> odds = List.of(1, 2, 3, 4, 5, 6).stream()
.filter(isOdd)
.toList();
System.out.println(odds); // [1, 3, 5]
Higher-Order Functions
A higher-order function is a method that accepts functions as parameters or returns a function as its result. This pattern enables the strategy pattern without class hierarchies, configurable behavior through function injection, and factory methods that produce specialized functions. Java's functional interfaces (FunctionPredicateConsumerSupplier
import java.util.function.*;
import java.util.List;
import java.util.Comparator;
// Method that ACCEPTS a function — strategy pattern via lambdas
static <T> List<T> transform(List<T> items, UnaryOperator<T> strategy) {
return items.stream()
.map(strategy)
.toList();
}
List<String> names = List.of("alice", "bob", "carol");
List<String> upper = transform(names, String::toUpperCase);
List<String> exclaimed = transform(names, s -> s + "!");
System.out.println(upper); // [ALICE, BOB, CAROL]
System.out.println(exclaimed); // [alice!, bob!, carol!]
// Method that RETURNS a function — function factory
static Predicate<String> longerThan(int minLength) {
return s -> s.length() > minLength;
}
List<String> longWords = List.of("hi", "hello", "hey", "greetings").stream()
.filter(longerThan(3))
.toList();
System.out.println(longWords); // [hello, greetings]
// Comparator factory — returns a comparator based on a key extractor
static <T> Comparator<T> compareBy(Function<T, Comparable> keyExtractor) {
return (a, b) -> keyExtractor.apply(a).compareTo(keyExtractor.apply(b));
}
record Employee(String name, int salary, String department) {}
List<Employee> employees = List.of(
new Employee("Alice", 95000, "Engineering"),
new Employee("Bob", 82000, "Marketing"),
new Employee("Carol", 105000, "Engineering")
);
// Sort by salary using our higher-order comparator factory
List<Employee> bySalary = employees.stream()
.sorted(compareBy(Employee::salary))
.toList();
// Decorator pattern — wrapping a function with logging
static <T, R> Function<T, R> withLogging(String label, Function<T, R> fn) {
return input -> {
System.out.printf("[%s] Input: %s%n", label, input);
R result = fn.apply(input);
System.out.printf("[%s] Output: %s%n", label, result);
return result;
};
}
Function<String, Integer> parseWithLog = withLogging("parse", Integer::parseInt);
int value = parseWithLog.apply("42");
// [parse] Input: 42
// [parse] Output: 42
// Retry wrapper — higher-order function for resilience
static <T, R> Function<T, R> withRetry(Function<T, R> fn, int maxRetries) {
return input -> {
Exception lastException = null;
for (int i = 0; i <= maxRetries; i++) {
try {
return fn.apply(input);
} catch (Exception e) {
lastException = e;
}
}
throw new RuntimeException("Failed after " + maxRetries + " retries", lastException);
};
}
Function<String, String> fetchUrl = withRetry(url -> httpGet(url), 3);
Currying & Partial Application
Currying transforms a function that takes multiple arguments into a chain of functions, each taking a single argument. Java does not have built-in syntax for currying, but you can simulate it using nested Function
import java.util.function.*;
// Currying: f(a, b, c) becomes f(a)(b)(c)
// A BiFunction turned into a curried chain of Functions
// Standard two-argument function
BiFunction<Integer, Integer, Integer> add = (a, b) -> a + b;
System.out.println(add.apply(3, 5)); // 8
// Curried version — Function that returns a Function
Function<Integer, Function<Integer, Integer>> curriedAdd =
a -> b -> a + b;
System.out.println(curriedAdd.apply(3).apply(5)); // 8
// Partial application — fix one argument to get a specialized function
Function<Integer, Integer> add10 = curriedAdd.apply(10);
System.out.println(add10.apply(5)); // 15
System.out.println(add10.apply(20)); // 30
// Three-argument curried function
Function<String, Function<String, Function<String, String>>> formatName =
title -> first -> last -> title + " " + first + " " + last;
// Partially apply title
Function<String, Function<String, String>> drName = formatName.apply("Dr.");
Function<String, String> drSmith = drName.apply("Smith");
System.out.println(drSmith.apply("John")); // Dr. Smith John
System.out.println(formatName.apply("Mr.").apply("Jane").apply("Doe"));
// Mr. Jane Doe
// Practical currying: configurable message formatter
Function<String, Function<String, Function<String, String>>> logFormatter =
level -> component -> message ->
String.format("[%s] [%s] %s", level, component, message);
// Create specialized loggers via partial application
Function<String, Function<String, String>> errorLogger = logFormatter.apply("ERROR");
Function<String, String> dbErrorLogger = errorLogger.apply("Database");
Function<String, String> apiErrorLogger = errorLogger.apply("API");
System.out.println(dbErrorLogger.apply("Connection timeout"));
// [ERROR] [Database] Connection timeout
System.out.println(apiErrorLogger.apply("Rate limit exceeded"));
// [ERROR] [API] Rate limit exceeded
// Generic curry helper method
static <A, B, R> Function<A, Function<B, R>> curry(BiFunction<A, B, R> biFunction) {
return a -> b -> biFunction.apply(a, b);
}
// Use the helper to curry any BiFunction
BiFunction<Double, Double, Double> power = Math::pow;
Function<Double, Function<Double, Double>> curriedPow = curry(power);
// won't work due to arg order
Function<Double, Double> square = curriedPow.apply(2.0).andThen(d -> d);
// Better:
Function<Double, Double> squareFn = base -> curriedPow.apply(base).apply(2.0);
System.out.println(squareFn.apply(5.0)); // 25.0
Monadic Patterns with Optional
Optionalmap()flatMap()Optionalor()
import java.util.Optional;
import java.util.function.Function;
// Traditional null-checking (error-prone, deeply nested)
String city = null;
if (user != null) {
Address address = user.getAddress();
if (address != null) {
city = address.getCity();
}
}
// Monadic style with Optional — flat, readable, no null checks
record Address(Optional<String> city, Optional<String> zip) {}
record User(String name, Optional<Address> address) {}
Optional<User> findUser(String id) {
// lookup in database...
return Optional.ofNullable(database.get(id));
}
// flatMap chains — each step may return empty
String cityName = findUser("123")
.flatMap(User::address)
.flatMap(Address::city)
.orElse("Unknown");
// map vs flatMap:
// map: Optional<T> -> (T -> R) -> Optional<R>
// flatMap: Optional<T> -> (T -> Optional<R>) -> Optional<R>
Optional<String> name = Optional.of(" Alice ");
Optional<String> trimmed = name.map(String::trim); // Optional["Alice"]
Optional<Integer> length = trimmed.map(String::length); // Optional[5]
// or() — provide a fallback Optional (Java 9+)
Optional<String> primary = findConfigValue("primary.db");
Optional<String> fallback = findConfigValue("fallback.db");
String dbUrl = primary
.or(() -> fallback)
.or(() -> Optional.of("jdbc:h2:mem:default"))
.get();
// orElseGet with lazy computation
String config = Optional.ofNullable(System.getenv("APP_CONFIG"))
.orElseGet(() -> loadDefaultConfig()); // only called if empty
// Chaining transformations — building a processing pipeline
record Order(String id, Optional<String> couponCode) {}
record Coupon(String code, double discountPercent) {}
Optional<Double> discount = findOrder("ORD-001")
.flatMap(Order::couponCode) // Optional<String>
.flatMap(code -> lookupCoupon(code)) // Optional<Coupon>
.map(Coupon::discountPercent) // Optional<Double>
.filter(d -> d > 0 && d <= 50); // validate range
double finalDiscount = discount.orElse(0.0);
// ifPresentOrElse (Java 9+) — handle both cases
findUser("456").ifPresentOrElse(
user -> System.out.println("Found: " + user.name()),
() -> System.out.println("User not found")
);
// stream() — bridge Optional to Stream (Java 9+)
// Useful in flatMap to filter out empty optionals from a stream
List<String> cities = userIds.stream()
.map(this::findUser) // Stream<Optional<User>>
.flatMap(Optional::stream) // Stream<User> (empty optionals removed)
.flatMap(u -> u.address().stream()) // Stream<Address>
.flatMap(a -> a.city().stream()) // Stream<String>
.distinct()
.toList();
The Either Pattern
The Either pattern provides a way to represent a computation that can produce one of two possible results: typically a success value (RightLeft
// Either<L, R> — Left for errors, Right for success values
sealed interface Either<L, R> {
record Left<L, R>(L value) implements Either<L, R> {}
record Right<L, R>(R value) implements Either<L, R> {}
static <L, R> Either<L, R> left(L value) {
return new Left<>(value);
}
static <L, R> Either<L, R> right(R value) {
return new Right<>(value);
}
// map — transform the Right value, leave Left unchanged
default <U> Either<L, U> map(Function<R, U> fn) {
return switch (this) {
case Right<L, R> r -> Either.right(fn.apply(r.value()));
case Left<L, R> l -> Either.left(l.value());
};
}
// flatMap — chain operations that return Either
default <U> Either<L, U> flatMap(Function<R, Either<L, U>> fn) {
return switch (this) {
case Right<L, R> r -> fn.apply(r.value());
case Left<L, R> l -> Either.left(l.value());
};
}
// fold — extract value from either side
default <U> U fold(Function<L, U> leftFn, Function<R, U> rightFn) {
return switch (this) {
case Right<L, R> r -> rightFn.apply(r.value());
case Left<L, R> l -> leftFn.apply(l.value());
};
}
default R getOrElse(R defaultValue) {
return switch (this) {
case Right<L, R> r -> r.value();
case Left<L, R> l -> defaultValue;
};
}
}
// Using Either for validation without exceptions
Either<String, Integer> parseAge(String input) {
try {
int age = Integer.parseInt(input);
if (age < 0 || age > 150) return Either.left("Age out of range: " + age);
return Either.right(age);
} catch (NumberFormatException e) {
return Either.left("Invalid number: " + input);
}
}
Either<String, String> validateName(String name) {
if (name == null || name.isBlank()) return Either.left("Name is required");
if (name.length() > 100) return Either.left("Name too long");
return Either.right(name.trim());
}
// Chaining Either operations — short-circuits on first Left
record Person(String name, int age) {}
Either<String, Person> createPerson(String nameInput, String ageInput) {
return validateName(nameInput)
.flatMap(name -> parseAge(ageInput)
.map(age -> new Person(name, age)));
}
// Usage
Either<String, Person> result = createPerson("Alice", "30");
String message = result.fold(
error -> "Error: " + error,
person -> "Created: " + person
);
System.out.println(message); // Created: Person[name=Alice, age=30]
Either<String, Person> failed = createPerson("", "30");
System.out.println(failed.fold(e -> "Error: " + e, p -> "OK"));
// Error: Name is required
Immutable Data Transformations
Functional programming favors transforming data into new structures rather than mutating existing ones. Java records are naturally immutable and pair well with stream pipelines to build transformation chains that produce new collections at each step. By avoiding mutation, each transformation is independent, testable, and safe for concurrent use. The with
import java.util.*;
import java.util.stream.*;
// Immutable records as data carriers
record Product(String name, String category, double price) {
// "with" methods return new instances — no mutation
Product withPrice(double newPrice) {
return new Product(name, category, newPrice);
}
Product withCategory(String newCategory) {
return new Product(name, newCategory, price);
}
}
record PriceSummary(String category, double avgPrice, long count) {}
// Immutable pipeline: filter -> transform -> group -> summarize
List<Product> catalog = List.of(
new Product("Laptop", "Electronics", 999.99),
new Product("Mouse", "Electronics", 29.99),
new Product("Desk", "Furniture", 349.00),
new Product("Chair", "Furniture", 499.00),
new Product("Monitor", "Electronics", 449.99),
new Product("Lamp", "Furniture", 79.00)
);
// Apply a 10% discount — produces a NEW list, original unchanged
List<Product> discounted = catalog.stream()
.map(p -> p.withPrice(p.price() * 0.9))
.toList();
// Group and summarize — no mutable accumulators
Map<String, PriceSummary> summaries = catalog.stream()
.collect(Collectors.groupingBy(
Product::category,
Collectors.collectingAndThen(
Collectors.toList(),
products -> new PriceSummary(
products.getFirst().category(),
products.stream().mapToDouble(Product::price).average().orElse(0),
products.size()
)
)
));
summaries.forEach((cat, summary) ->
System.out.printf("%s: avg=%.2f, count=%d%n",
cat, summary.avgPrice(), summary.count()));
// Electronics: avg=493.32, count=3
// Furniture: avg=309.00, count=3
// Chained transformations — each step is pure (no side effects)
record OrderLine(String product, int qty, double unitPrice) {
double total() { return qty * unitPrice; }
}
record OrderSummary(int totalItems, double totalCost, double avgItemCost) {}
List<OrderLine> orderLines = List.of(
new OrderLine("Widget", 5, 10.0),
new OrderLine("Gadget", 2, 25.0),
new OrderLine("Gizmo", 8, 3.50)
);
OrderSummary summary = orderLines.stream()
.reduce(
new OrderSummary(0, 0, 0),
(acc, line) -> {
int newItems = acc.totalItems() + line.qty();
double newCost = acc.totalCost() + line.total();
return new OrderSummary(newItems, newCost, newCost / newItems);
},
(a, b) -> {
int items = a.totalItems() + b.totalItems();
double cost = a.totalCost() + b.totalCost();
return new OrderSummary(items, cost, cost / items);
}
);
System.out.printf("Items: %d, Total: $%.2f, Avg: $%.2f%n",
summary.totalItems(), summary.totalCost(), summary.avgItemCost());
// Items: 15, Total: $128.00, Avg: $8.53
Lazy Evaluation with Supplier
Supplier<T>
import java.util.function.Supplier;
import java.util.concurrent.ConcurrentHashMap;
// Basic lazy evaluation — computation deferred until get() is called
Supplier<List<String>> lazyData = () -> {
System.out.println("Loading data..."); // only printed when get() is called
return loadFromDatabase();
};
// Nothing happens yet — no database call
System.out.println("Before access");
// Now the computation runs
List<String> data = lazyData.get();
System.out.println("Loaded " + data.size() + " records");
// Memoized Supplier — compute once, cache forever
static <T> Supplier<T> memoize(Supplier<T> delegate) {
// Using a single-element array to hold the cached value
Object[] cache = new Object[]{null};
boolean[] computed = {false};
return () -> {
if (!computed[0]) {
synchronized (cache) {
if (!computed[0]) {
cache[0] = delegate.get();
computed[0] = true;
}
}
}
@SuppressWarnings("unchecked")
T result = (T) cache[0];
return result;
};
}
// Usage — expensive config loaded at most once
Supplier<Map<String, String>> config = memoize(() -> {
System.out.println("Parsing config file...");
return parseConfigFile("/etc/app/config.yml");
});
// First call triggers computation
Map<String, String> c1 = config.get(); // prints "Parsing config file..."
// Second call returns cached result
Map<String, String> c2 = config.get(); // no print — cached
// Lazy initialization in a class
class ExpensiveService {
private final Supplier<DatabaseConnection> connection;
ExpensiveService(String connectionUrl) {
// Connection is not created until first use
this.connection = memoize(() -> new DatabaseConnection(connectionUrl));
}
public String query(String sql) {
// Connection created on first query, reused afterward
return connection.get().execute(sql);
}
}
// Conditional evaluation — avoid expensive computation when not needed
static <T> T getOrDefault(Supplier<T> expensiveSupplier, T defaultValue, boolean needed) {
return needed ? expensiveSupplier.get() : defaultValue;
}
// The expensive report is only generated if the user requested it
String report = getOrDefault(
() -> generateFullAuditReport(), // expensive — only runs if needed
"N/A",
userRequestedAudit
);
Pipeline Pattern
The pipeline pattern chains a series of FunctionUnaryOperatorUnaryOperator<T>Function<T,T>
import java.util.function.*;
import java.util.List;
import java.util.ArrayList;
// Simple pipeline with UnaryOperator chaining
UnaryOperator<String> trimStep = String::trim;
UnaryOperator<String> lowerStep = String::toLowerCase;
UnaryOperator<String> removeSpecialChars = s -> s.replaceAll("[^a-z0-9\\s]", "");
UnaryOperator<String> normalizeSpaces = s -> s.replaceAll("\\s+", " ");
// Build the pipeline by chaining andThen
UnaryOperator<String> normalizer = s ->
trimStep
.andThen(lowerStep)
.andThen(removeSpecialChars)
.andThen(normalizeSpaces)
.apply(s);
System.out.println(normalizer.apply(" Hello, World!! How's it? "));
// "hello world hows it"
// Reusable Pipeline class
class Pipeline<T> {
private final List<UnaryOperator<T>> steps = new ArrayList<>();
Pipeline<T> addStep(UnaryOperator<T> step) {
steps.add(step);
return this; // fluent API
}
T execute(T input) {
T result = input;
for (UnaryOperator<T> step : steps) {
result = step.apply(result);
}
return result;
}
// Build a single composed function from all steps
UnaryOperator<T> build() {
return steps.stream()
.reduce(UnaryOperator.identity(), (a, b) -> a.andThen(b)::apply);
}
}
// Request processing pipeline
record HttpRequest(String path, Map<String, String> headers, String body) {
HttpRequest withHeader(String key, String value) {
var newHeaders = new HashMap<>(headers);
newHeaders.put(key, value);
return new HttpRequest(path, Map.copyOf(newHeaders), body);
}
HttpRequest withBody(String newBody) {
return new HttpRequest(path, headers, newBody);
}
}
Pipeline<HttpRequest> requestPipeline = new Pipeline<HttpRequest>()
.addStep(req -> req.withHeader("X-Request-Id", generateRequestId()))
.addStep(req -> req.withHeader("Authorization", "Bearer " + getToken()))
.addStep(req -> req.withBody(compressBody(req.body())))
.addStep(req -> req.withHeader("Content-Encoding", "gzip"));
HttpRequest prepared = requestPipeline.execute(
new HttpRequest("/api/data", Map.of(), "{\"key\":\"value\"}")
);
// Conditional pipeline — add steps based on configuration
Pipeline<String> textPipeline = new Pipeline<>();
textPipeline.addStep(String::trim);
if (config.shouldLowerCase()) {
textPipeline.addStep(String::toLowerCase);
}
if (config.shouldRemoveHtml()) {
textPipeline.addStep(s -> s.replaceAll("<[^>]+>", ""));
}
if (config.maxLength() > 0) {
textPipeline.addStep(s -> s.length() > config.maxLength()
? s.substring(0, config.maxLength()) + "..."
: s);
}
String cleaned = textPipeline.execute(rawInput);
// Function pipeline with type changes using Function instead of UnaryOperator
Function<String, String> validate = input -> {
if (input == null || input.isBlank()) throw new IllegalArgumentException("Empty");
return input;
};
Function<String, List<String>> split = s -> List.of(s.split(","));
Function<List<String>, List<String>> trimAll = list ->
list.stream().map(String::trim).toList();
Function<List<String>, Map<String, Integer>> countOccurrences = list ->
list.stream().collect(Collectors.groupingBy(
Function.identity(), Collectors.summingInt(e -> 1)));
// Compose the full pipeline
Function<String, Map<String, Integer>> csvCounter = validate
.andThen(split)
.andThen(trimAll)
.andThen(countOccurrences);
Map<String, Integer> counts = csvCounter.apply("a, b, a, c, b, a");
System.out.println(counts); // {a=3, b=2, c=1}