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> interface provides andThen() and compose() for chaining functions. andThen() applies the current function first, then the argument function. compose() does the reverse: it applies the argument function first. Predicate supports and(), or(), and negate() for combining boolean-valued functions.

Java
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 (Function, Predicate, Consumer, Supplier) make it natural to pass behavior as data.

Java
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 returns. Partial application is the related technique of fixing some arguments of a function to produce a new function with fewer parameters. Both patterns enable creating specialized functions from general ones without code duplication.

Java
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

Optional is Java's approximation of a monad: a container type that supports map() for transforming the contained value, flatMap() for chaining operations that themselves return Optional, and or() for providing fallback optionals. By chaining these operations, you can eliminate nested null checks and build readable data-extraction pipelines that gracefully handle absent values at every step.

Java
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 (Right) or an error value (Left). Unlike exceptions, Either makes error handling explicit in the type system and enables functional composition of operations that may fail. Java does not include Either in the standard library, but it is straightforward to build a sealed-class implementation.

Java
// 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-style copy methods let you create modified copies of records without altering the original.

Java
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> represents a computation that has not yet been executed. By wrapping expensive operations in a Supplier, you defer execution until the value is actually needed, and can choose to never execute it at all. Combined with memoization (caching the result after first computation), Supplier provides a lightweight lazy-evaluation mechanism without external libraries.

Java
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 Function or UnaryOperator steps into a single composite transformation. Unlike streams (which operate on collections of elements), a pipeline transforms a single value through multiple stages. This is ideal for request processing, data validation, text normalization, and any scenario where an object passes through a sequence of transformations. UnaryOperator<T> (a Function<T,T> where input and output types match) is the natural choice for pipeline steps.

Java
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}